Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ DATA_RETENTION_DAYS=0
# CORS (set to your domain in production)
# CORS_ORIGINS=https://teslasync.example.com

# Android app link verification (only needed for a Trusted Web Activity build,
# see apps/twa). JSON statement list served at /.well-known/assetlinks.json;
# fingerprints come from your TWA signing key. Empty disables the endpoint.
# ANDROID_ASSETLINKS_JSON=[{"relation":["delegate_permission/common.handle_all_urls"],"target":{"namespace":"android_app","package_name":"com.example.teslasync","sha256_cert_fingerprints":["AA:BB:CC:DD"]}}]

# Encryption key for tokens at rest (recommended for production)
# Generate with: openssl rand -base64 32
# ENCRYPTION_KEY=
Expand Down
145 changes: 142 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ jobs:
# ── Stage 3: PUBLISH — gated on the vulnerability verdict ───────────
docker:
name: Publish · ${{ matrix.image }}
needs: [version, vulnerability-report]
needs: [version, vulnerability-report, windows-apps, android-app]
runs-on: ${{ inputs.runner || 'ubuntu-latest' }}
permissions:
contents: read
Expand Down Expand Up @@ -663,6 +663,105 @@ jobs:
--app-version "$VERSION"
helm push teslasync-*.tgz "oci://ghcr.io/${OWNER}/charts"

windows-apps:
name: Build Windows installers
needs: version
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: '26'

- name: Build bundled web UI
env:
VITE_APP_VERSION: ${{ needs.version.outputs.version }}
VITE_GIT_SHA: ${{ github.sha }}
VITE_RELEASE_BUILD: '1'
run: |
npm ci --prefix web
npm run build --prefix web

- name: Build Windows packages
shell: pwsh
env:
VERSION: ${{ needs.version.outputs.version }}
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: |
npm ci --prefix apps/desktop
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
npm version $env:VERSION --no-git-tag-version --prefix apps/desktop
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
npm run dist --prefix apps/desktop -- --win nsis appx
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$appx = @(Get-ChildItem apps/desktop/release -Filter *.appx)
$exe = @(Get-ChildItem apps/desktop/release -Filter *.exe)
if ($appx.Count -ne 1 -or $exe.Count -ne 1) {
throw "Expected one AppX and one NSIS installer; found $($appx.Count) and $($exe.Count)."
}
./apps/desktop/package-msix.ps1 -Appx $appx[0].FullName -Msix "apps/desktop/release/TeslaSync-$env:VERSION.msix"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
New-Item -ItemType Directory -Path release-assets | Out-Null
Copy-Item $exe[0].FullName "release-assets/TeslaSync-$env:VERSION-setup.exe"
Copy-Item "apps/desktop/release/TeslaSync-$env:VERSION.msix" release-assets/

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-apps
path: release-assets/*
if-no-files-found: error

android-app:
name: Build Android test APK
needs: version
# The Android SDK is preinstalled on GitHub-hosted Ubuntu runners.
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: '26'

- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: '21'

- name: Build bundled web UI
env:
VITE_APP_VERSION: ${{ needs.version.outputs.version }}
VITE_GIT_SHA: ${{ github.sha }}
VITE_RELEASE_BUILD: '1'
run: |
npm ci --prefix web
npm run build --prefix web

- name: Build debug-signed test APK
env:
VERSION: ${{ needs.version.outputs.version }}
VERSION_CODE: ${{ github.run_number }}
run: |
set -euo pipefail
npm ci --prefix apps/mobile
(cd apps/mobile && npx cap sync android)
(cd apps/mobile/android && bash gradlew assembleDebug \
"-PreleaseVersionName=$VERSION" "-PreleaseVersionCode=$VERSION_CODE")
mkdir -p release-assets
cp apps/mobile/android/app/build/outputs/apk/debug/app-debug.apk \
"release-assets/TeslaSync-${VERSION}-debug.apk"

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: android-app
path: release-assets/*
if-no-files-found: error

# ── Stage 4: TAG AND ANNOUNCE — the last publish side effect ────────
#
# The git tag is created HERE, not in the version job. A tag is a
Expand All @@ -671,8 +770,8 @@ jobs:
# claiming a version that was never fit to ship.
release-notes:
name: Tag & create GitHub Release
needs: [version, docker, helm, vulnerability-report]
if: ${{ !cancelled() && needs.docker.result == 'success' && needs.helm.result == 'success' && needs.vulnerability-report.result == 'success' }}
needs: [version, docker, helm, vulnerability-report, windows-apps, android-app]
if: ${{ !cancelled() && needs.docker.result == 'success' && needs.helm.result == 'success' && needs.vulnerability-report.result == 'success' && needs.windows-apps.result == 'success' && needs.android-app.result == 'success' }}
runs-on: ${{ inputs.runner || 'ubuntu-latest' }}
permissions:
contents: write
Expand All @@ -682,6 +781,23 @@ jobs:
with:
fetch-depth: 0

- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: windows-apps
path: release-assets

- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: android-app
path: release-assets

- name: Verify downloadable apps
run: |
set -euo pipefail
test "$(find release-assets -maxdepth 1 -name '*.exe' | wc -l)" -eq 1
test "$(find release-assets -maxdepth 1 -name '*.msix' | wc -l)" -eq 1
test "$(find release-assets -maxdepth 1 -name '*.apk' | wc -l)" -eq 1

- name: Create and push tag
env:
NEW_TAG: ${{ needs.version.outputs.new_tag }}
Expand Down Expand Up @@ -762,6 +878,17 @@ jobs:
helm install teslasync oci://ghcr.io/${{ github.repository_owner }}/charts/teslasync --version ${VERSION}
\`\`\`

### 📱 Desktop and Android downloads

Download the Windows NSIS installer (\`.exe\`), unsigned Windows package
(\`.msix\`), or debug-signed Android test build (\`-debug.apk\`) from the
assets below. The MSIX needs signing with a trusted publisher certificate
before sideloading; its default publisher is a placeholder, not a Store
identity. The debug APK is for testing, **not** Play Store distribution.
Its signing key changes per CI run; uninstall an older CI APK before
installing a newer one (this clears local app data).
Windows may warn about the unsigned installer.

### 🔐 Image Verification

All images are signed with [cosign](https://github.com/sigstore/cosign) (keyless, via GitHub OIDC), ship a CycloneDX SBOM attestation, and carry SLSA build provenance:
Expand Down Expand Up @@ -811,6 +938,9 @@ jobs:
| Automation Worker | \`ghcr.io/${{ github.repository }}-automation-worker:${VERSION}\` |
| Fleet Telemetry | \`ghcr.io/${{ github.repository }}-fleet-telemetry:${VERSION}\` |
| Helm Chart | \`oci://ghcr.io/${{ github.repository_owner }}/charts/teslasync:${VERSION}\` |
| Windows installer | \`TeslaSync-${VERSION}-setup.exe\` |
| Windows MSIX (unsigned) | \`TeslaSync-${VERSION}.msix\` |
| Android test APK (debug-signed) | \`TeslaSync-${VERSION}-debug.apk\` |
NOTES_EOF

- name: Create GitHub Release
Expand All @@ -819,8 +949,17 @@ jobs:
tag_name: ${{ needs.version.outputs.new_tag }}
name: TeslaSync ${{ needs.version.outputs.new_tag }}
body_path: release-notes.md
draft: true
prerelease: ${{ needs.version.outputs.is_prerelease == 'true' }}
generate_release_notes: false
files: release-assets/*
fail_on_unmatched_files: true

- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.version.outputs.new_tag }}
run: gh release edit "$TAG" --draft=false

# OPS-07 — Grafana release annotation.
#
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
release/
79 changes: 79 additions & 0 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# TeslaSync desktop app (Electron — Windows)

Generic desktop shell around the web UI: one build serves every
self-hosted server — the app boots to the server picker (`/connect`)
on first launch, where the user enters their server address and (on
forward-auth servers) pastes an API key with **App sign-in** enabled.
The [privacy policy](https://teslasync.dev/privacy) explains what the
app stores locally and what is sent to the selected server.

## Run

```sh
cd apps/desktop
npm install
npm run build:web # builds ../../web/dist (skip if already built)
npm start
```

## Ship (Windows)

```sh
npm run dist # → release/TeslaSync-*.exe (NSIS installer)
```

Run on Windows to create an NSIS installer; signing and automatic
updates are **not** configured. `npm run dist:dir` produces an unpacked
folder for smoke-testing. Only Windows distribution is configured;
macOS and Linux require separate targets and testing.

## MSIX/AppX package (local validation)

Builds an **unsigned** AppX locally, then repacks it with the Windows
SDK's `makeappx.exe` into a real MSIX package (and verifies it can be
unpacked). Do not rename an `.appx` file to `.msix`. Installing it
requires a signing certificate, a matching
`appx.publisher` value, and a trusted certificate on the target
machine. Store submissions require your Partner Center publisher
identity.

```powershell
cd apps\desktop
npm run dist:appx # → release\TeslaSync-*.appx
.\package-msix.ps1 -Appx 'release\TeslaSync 2.0.0.appx' -Msix 'release\TeslaSync-test.msix'
```

For local testing, configure electron-builder with a test PFX via
`CSC_LINK` and `CSC_KEY_PASSWORD`, set `appx.publisher` to the exact
certificate subject, and trust the certificate on your test device.
Then enable Windows Developer Mode and install the signed package with
`Add-AppxPackage`. The default `CN=TeslaSync` is only an example; do
not submit it to a store unchanged.

The release workflow attaches the unsigned `.msix` and unsigned NSIS
`.exe` installer to each GitHub Release. The MSIX is **not installable
without signing**; the installer may trigger a Windows reputation
warning. Neither asset is submitted to the Microsoft Store automatically.

## What's inside

- `main.js` — serves `web/dist` from the stable
`teslasync-app://app` origin (so server settings persist), with SPA
fallback, single-instance lock, `teslasync://` link handling, and
a theme-matched Windows title bar. No Node access from the page
(`contextIsolation` + `sandbox`).
- `preload.js` — exposes the shell marker and deep-link events, and keeps
the native window controls and running taskbar icon synchronized with
the app's theme. The packaged AppX tile and installer shortcut icons
remain the build-time red artwork; they cannot follow arbitrary
per-user colors selected after installation.
- `electron-builder.yml` — NSIS installer config, `teslasync://`
protocol registration on install.

## Notes

- Login is the server's own: open-mode servers need just the address;
forward-auth servers need an API key with **App sign-in** checked
(Admin → API Keys on the server).
- During development, rebuild `web/dist` to pick up UI changes;
installed users need a new installer for bundled UI changes.
44 changes: 44 additions & 0 deletions apps/desktop/assets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
'use strict';

const fs = require('node:fs');
const path = require('node:path');

const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};

function resolveAsset(root, url) {
let pathname;
try {
pathname = decodeURIComponent(new URL(url).pathname);
} catch {
return null;
}
const segments = pathname.split(/[\\/]/);
if (segments.some((segment) => segment === '..' || segment.startsWith('.'))) return null;
const file = path.resolve(root, `.${pathname}`);
const relative = path.relative(root, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
try {
if (fs.statSync(file).isFile()) return file;
} catch {
// Client-side routes fall back to the bundled entry point.
}
return path.extname(file) ? null : path.join(root, 'index.html');
}

module.exports = { resolveAsset, MIME };
21 changes: 21 additions & 0 deletions apps/desktop/assets.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use strict';

const assert = require('node:assert/strict');
const path = require('node:path');
const { test } = require('node:test');
const { resolveAsset } = require('./assets');

const root = path.resolve(__dirname, '..', '..', 'web', 'dist');

test('assets and SPA routes use the bundled web build', () => {
assert.equal(resolveAsset(root, 'teslasync-app://app/'), path.join(root, 'index.html'));
assert.equal(resolveAsset(root, 'teslasync-app://app/drives'), path.join(root, 'index.html'));
assert.equal(resolveAsset(root, 'teslasync-app://app/manifest.webmanifest'), path.join(root, 'manifest.webmanifest'));
});

test('dotfiles and traversal cannot escape the bundle', () => {
assert.equal(resolveAsset(root, 'teslasync-app://app/.secret'), null);
assert.equal(resolveAsset(root, 'teslasync-app://app/%2e%2e%5csecret'), null);
assert.equal(resolveAsset(root, 'teslasync-app://app/absent.js'), null);
assert.equal(resolveAsset(root, 'teslasync-app://app/%ZZ'), null);
});
Binary file added apps/desktop/assets/icon.ico
Binary file not shown.
31 changes: 31 additions & 0 deletions apps/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
appId: com.teslasync.app
productName: TeslaSync
copyright: Copyright © TeslaSync contributors
files:
- main.js
- assets.js
- titlebar.js
- preload.js
- package.json
extraResources:
- from: ../../web/dist
to: web/dist
directories:
output: release
win:
icon: assets/icon.ico
target:
- nsis
protocols:
- name: TeslaSync
schemes:
- teslasync
appx:
identityName: TeslaSync.TeslaSync
publisher: CN=TeslaSync
publisherDisplayName: TeslaSync
backgroundColor: '#0b0d12'
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
shortcutName: TeslaSync
Loading
Loading