23 Commits

Author SHA1 Message Date
Dan Ditomaso
40c2bfa535 Add pnpm install to release step (#795)
* more github changes

* moar changes
2025-08-19 14:50:54 -04:00
Dan Ditomaso
a10023fad6 more github changes (#794) 2025-08-19 13:19:28 -04:00
Dan Ditomaso
0a1afa988e update order of steps in nightly github action (#793) 2025-08-19 11:33:00 -04:00
Dan Ditomaso
97b884f119 add lock file (#792) 2025-08-18 09:19:34 -04:00
Dan Ditomaso
eb9b7159d4 Refactor github actions with monorepo support (#783)
* refactor github actions with monorepo support

* Update .github/workflows/release-packages.yml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update .github/workflows/release-packages.yml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* updates

* changed order of ci/pr steps

* Update .github/workflows/release-web.yml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* adding lock file

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-18 09:07:13 -04:00
Dan Ditomaso
65a53388bb Fix docker nginx config (#786)
* Fix docker-nginx-config sub directory issue

* Fix docker-nginx-config sub directory issue

* Update packages/web/infra/default.conf

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/web/infra/default.conf

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* adding lock file

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-18 09:06:48 -04:00
Jeremy Gallant
2ca3eb5685 Missing validation strings (#791)
Partially revert 43143bf

Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-08-18 09:03:15 -04:00
Dan Ditomaso
a4c817cd30 Added contribution guidelines doc (#788)
* added contribution guidelines doc

* Update packages/web/CONTRIBUTIONS.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-17 14:32:10 -04:00
github-actions[bot]
d5b03c09db chore(i18n): New Crowdin Translations by GitHub Action (#784)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-08-17 11:43:18 -04:00
Henri Bergius
0b9ebade38 Initial Node.js serial transport (#779)
* Initial Node.js serial transport

* Minor doc fixes

* Add serialport to lockfile

* Typo fix

* Fix link:
2025-08-14 21:56:34 -04:00
Dan Ditomaso
ee1758a548 Add DFU mode to command menu (#781)
* feat: add dfu mode to command menu

* Update packages/web/src/components/CommandPalette/index.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-13 21:24:45 -04:00
Dan Ditomaso
a2a45ac898 Update release-web.yml (#777) 2025-08-11 13:55:24 -04:00
Dan Ditomaso
59d172765d Update release-web.yml (#776) 2025-08-11 13:48:35 -04:00
Jeremy Gallant
d453ff809a Refactor and consolidate store imports (#774)
* Refactor  and consolitdate store imports

- Created a new index file in the core stores directory to export all stores from a single module.
- Updated imports to use consolidated store exports.

* Remove unnecessary import

* Update imports

* Use named exports

* Change store import after merge

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-08-11 12:36:33 -04:00
Dan Ditomaso
ed0a99dbd9 Update release-web.yml (#775)
added adhoc runs.
2025-08-11 11:20:48 -04:00
Jeremy Gallant
32f31cb502 Add client notification (#771)
* ClientNotification WIP

* Test

* ClientNotification WIP

* Add client notification dialog and related functionality

* Update ClientNotificationDialog.tsx

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-08-10 21:59:03 -04:00
Jeremy Gallant
176d554ef9 Improve NodeDetailsDialog UI and add security info (#770)
* Improve NodeDetailsDialog UI and add security info

Refactored NodeDetailsDialog to use tables for better layout and readability, added a security section displaying public key and verification status, and included messageable status. Updated i18n files with new keys and improved battery level formatting. Fixed logic in Nodes page for handling location packets and improved hardware model sorting.

* Update NodeDetailsDialog.tsx
2025-08-10 21:58:26 -04:00
Jeremy Gallant
2735c37fad Fix Docker and CI builds (#773)
* Fix Docker and CI builds

* Fix indentation

* Fix release

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-08-10 21:58:09 -04:00
Jeremy Gallant
f04ec36faf Update VSCode settings (#769)
Update extensions

Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-08-10 07:50:51 -04:00
github-actions[bot]
28f0ca4337 chore(i18n): New Crowdin Translations by GitHub Action (#772)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-08-10 07:50:30 -04:00
Jeremy Gallant
1dbf0b07b6 refactor-ota-dialog (#768)
Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-08-10 07:49:42 -04:00
Jeremy Gallant
27ed4e58bd Fix admin PKI validation (#766)
Admin PKI fields were falsely flagged as unchanged.

Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-08-09 22:38:59 -04:00
Jeremy Gallant
a7f56c0bd5 Fix checkbox tests (#767)
Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-08-09 22:37:31 -04:00
129 changed files with 2136 additions and 1019 deletions

View File

@@ -15,16 +15,18 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
cache: pnpm
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup Deno
uses: denoland/setup-deno@v2
@@ -59,7 +61,7 @@ jobs:
if [[ -f "$pkg_dir/package.json" ]] && [[ "$pkg_dir" != "packages/web" ]]; then
echo "🔧 Building with pnpm: $pkg_dir"
(cd "$pkg_dir" && pnpm install && pnpm run build:npm)
(cd "$pkg_dir" && pnpm install --frozen-lockfile && pnpm run build:npm)
else
echo "⚠️ Skipping $pkg_dir (web package or no package.json)"
fi

View File

@@ -1,92 +1,100 @@
name: "Nightly Release"
name: Nightly Release
on:
schedule:
- cron: "0 5 * * *" # Run every day at 5am UTC
- cron: "0 5 * * *" # 05:00 UTC daily
workflow_dispatch: {} # allow manual runs too
permissions:
contents: write
contents: read
packages: write
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
jobs:
nightly-build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Cache pnpm dependencies
uses: actions/cache@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
path: |
~/.pnpm-store
packages/web/node_modules
key: ${{ runner.os }}-pnpm-${{ hashFiles('packages/web/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
node-version: 22
cache: pnpm
cache-dependency-path: '**/pnpm-lock.yaml'
# - name: Run tests
# working-directory: packages/web
# run: deno task test
- name: Install dependencies (root)
run: pnpm install --frozen-lockfile
- name: Install Dependencies
working-directory: packages/web
run: pnpm install
- name: Run tests
run: pnpm run test
- name: Build Package
- name: Build web package
working-directory: packages/web
run: pnpm run build
- name: Package Output
- name: Package output
working-directory: packages/web
run: pnpm run package
- name: Archive compressed build
- name: Upload compressed build (artifact)
uses: actions/upload-artifact@v4
with:
name: build
name: web-build-nightly
path: packages/web/dist/build.tar
if-no-files-found: error
- name: Get latest release version
id: get_release
- name: Determine nightly tag
id: meta
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
LATEST_TAG=$(curl -sL \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r ".tag_name")
# Fallback to a default if no release is found
if [ -z "$LATEST_TAG" ]; then
LATEST_TAG="2.6.0"
set -euo pipefail
DATE="$(date -u +%Y%m%d)"
SHORTSHA="$(git rev-parse --short=12 HEAD)"
# Try to use latest release tag if it exists; fallback to package version; else date
LATEST_TAG="$(gh release view --json tagName --jq .tagName 2>/dev/null || true)"
if [ -z "$LATEST_TAG" ] && [ -f packages/web/package.json ]; then
LATEST_TAG="v$(jq -r .version packages/web/package.json)"
fi
echo "tag=${LATEST_TAG}" >> $GITHUB_OUTPUT
if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" != "vnull" ]; then
TAG="nightly-${LATEST_TAG}-${SHORTSHA}"
else
TAG="nightly-${DATE}-${SHORTSHA}"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "tags=nightly, $TAG" >> "$GITHUB_OUTPUT"
echo "Resolved nightly tags: nightly, $TAG"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Buildah Build
- name: Build Container Image (multi-arch)
id: build-container
uses: redhat-actions/buildah-build@v2
with:
containerfiles: |
./packages/web/infra/Containerfile
image: ${{ github.event.repository.full_name }}
tags: nightly-${{ steps.get_release.outputs.tag }}-${{ github.sha }}
image: ${{ env.REGISTRY_IMAGE }}
tags: ${{ steps.meta.outputs.tags }}
oci: true
platforms: linux/amd64, linux/arm64
labels: |
org.opencontainers.image.source=${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ github.run_id }}
- name: Push To Registry
- name: Push To GHCR
id: push-to-registry
uses: redhat-actions/push-to-registry@v2
with:
@@ -96,6 +104,5 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Print image url
- name: Print image URL
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"

View File

@@ -1,49 +1,52 @@
name: Pull Request CI
on: pull_request
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: write
packages: write
contents: read
concurrency:
group: pr-${{ github.event.pull_request.number }}-ci
cancel-in-progress: true
env:
CI: true
jobs:
build-and-package:
runs-on: ubuntu-latest
# Skip for draft PRs; remove this line if you want to run on drafts too
if: ${{ github.event.pull_request.draft == false }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Install Dependencies
# Commands will run from 'packages/web'
working-directory: packages/web
run: pnpm install
- name: Cache pnpm dependencies
uses: actions/cache@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
path: |
~/.pnpm-store
packages/web/node_modules
key: ${{ runner.os }}-pnpm-${{ hashFiles('packages/web/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
node-version: 22
cache: pnpm
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run linter
run: pnpm run lint
- name: Check formatter
- name: Check formatter
run: pnpm run check
- name: Build Package
working-directory: packages/web
run: pnpm run build
- name: Run tests
run: pnpm run test
- name: Build web package
run: pnpm --filter "./packages/web" run build

View File

@@ -11,76 +11,103 @@ on:
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # <-- required for JSR OIDC
steps:
- name: Checkout code
uses: actions/checkout@v4
# --- Setup Node.js and pnpm ---
- name: Setup Node.js
# Node + pnpm (with cache)
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
# --- Setup Deno ---
- name: Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
# --- Cache pnpm Dependencies ---
- name: Cache pnpm Dependencies
uses: actions/cache@v4
with:
path: |
~/.pnpm-store
packages/web/node_modules
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
# --- Cache Deno Dependencies ---
- name: Cache Deno Dependencies
uses: actions/cache@v4
with:
path: ~/.cache/deno
key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
restore-keys: |
${{ runner.os }}-deno-
- name: Configure pnpm registry
run: pnpm config set registry https://registry.npmjs.org/
- name: Configure pnpm auth
run: pnpm config set //registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}
- name: Publish packages to npm and JSR
- name: Configure npm auth
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
for dir in packages/*; do
echo "Processing $dir"
pnpm config set //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}
pnpm config set registry https://registry.npmjs.org/
cd $dir
- name: Install deps (root)
run: pnpm install
# Build and publish to npm if package.json exists
if [ -f "package.json" ]; then
echo "Building and publishing $dir to npm..."
pnpm run build:npm
pnpm run publish:npm || echo "npm publish failed for $dir"
fi
- name: Resolve package list
id: pkgs
shell: bash
run: |
if [ "${{ github.event.inputs.packages }}" = "all" ] || [ -z "${{ github.event.inputs.packages }}" ]; then
mapfile -t TARGETS < <(ls -d packages/* | grep -v 'packages/web')
else
IFS=',' read -ra TARGETS <<< "${{ github.event.inputs.packages }}"
TARGETS=("${TARGETS[@]/#/packages/}")
fi
printf '%s\n' "${TARGETS[@]}" | paste -sd, - > targets.txt
echo "list=$(cat targets.txt)" >> "$GITHUB_OUTPUT"
pnpm run prepare:jsr
# Publish to JSR if jsr.json exists
if [ -f "jsr.json" ]; then
echo "Publishing $dir to jsr..."
deno publish || echo "JSR publish failed for $dir"
fi
cd - > /dev/null
- name: Build selected packages (tsdown)
run: |
IFS=',' read -ra TARGETS <<< "${{ steps.pkgs.outputs.list }}"
for dir in "${TARGETS[@]}"; do
echo "Building $dir"
pnpm --filter "./$dir" run build
done
- name: Sync jsr.json version from package.json
run: |
IFS=',' read -ra TARGETS <<< "${{ steps.pkgs.outputs.list }}"
for dir in "${TARGETS[@]}"; do
if [ -f "$dir/jsr.json" ] && [ -f "$dir/package.json" ]; then
PKG_VER=$(jq -r .version "$dir/package.json")
jq --arg v "$PKG_VER" '.version = $v' "$dir/jsr.json" > "$dir/jsr.json.tmp" && mv "$dir/jsr.json.tmp" "$dir/jsr.json"
echo "Updated $dir/jsr.json to version $PKG_VER"
fi
done
- name: Publish to JSR (OIDC)
run: |
set -euo pipefail
IFS=',' read -ra TARGETS <<< "${{ steps.pkgs.outputs.list }}"
for dir in "${TARGETS[@]}"; do
if [ -f "$dir/jsr.json" ]; then
echo "Publishing $dir to JSR via OIDC…"
cd "$dir"
[ -d dist ] || pnpm run build
if ! npx --yes jsr publish 2>&1 | tee jsr_publish.log; then
echo "JSR publish failed for $dir. Error output:"
cat jsr_publish.log
fi
cd - >/dev/null
fi
done
- name: Replace exports entry in package.json
run: |
tmp=$(mktemp)
jq '.exports["."] = "./dist/mod.mjs"' package.json > "$tmp" \
&& mv "$tmp" package.json
- name: Publish to npm
run: |
set -euo pipefail
IFS=',' read -ra TARGETS <<< "${{ steps.pkgs.outputs.list }}"
for dir in "${TARGETS[@]}"; do
if [ -f "$dir/package.json" ]; then
echo "Publishing $dir to npm…"
cd "$dir"
[ -d dist ] || pnpm run build
npm publish --access public || echo "npm publish failed for $dir"
cd - >/dev/null
fi
done

View File

@@ -3,9 +3,28 @@ name: Release Web
on:
release:
types: [released, prereleased]
workflow_dispatch:
inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build"
required: false
default: ""
tag_name:
description: "Tag to use for artifacts/images (defaults to <sha>)"
required: false
default: ""
attach_to_release:
description: "Upload build.tar to an existing GitHub Release (requires tag_name)"
required: false
type: boolean
default: false
permissions:
contents: write
packages: write
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
jobs:
release-web:
@@ -14,54 +33,113 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
# For manual runs, allow building a chosen ref (branch/tag/SHA)
ref: ${{ inputs.ref != '' && inputs.ref || github.ref }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Determine tag & latest flag
id: meta
shell: bash
run: |
# Determine TAG
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
# Push "latest" only for full releases (not prereleases)
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
PUSH_LATEST="false"
else
PUSH_LATEST="true"
fi
elif [ -n "${{ inputs.tag_name }}" ]; then
TAG="${{ inputs.tag_name }}"
PUSH_LATEST="false"
else
SHA="$(git rev-parse --short=12 HEAD)"
TAG="adhoc-${SHA}"
PUSH_LATEST="false"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "push_latest=$PUSH_LATEST" >> "$GITHUB_OUTPUT"
echo "Resolved tag: $TAG (push_latest=$PUSH_LATEST)"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
bun-version: latest
version: latest
- name: Cache Bun Dependencies
uses: actions/cache@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
path: |
~/.bun/install/cache
packages/web/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
node-version: 22
cache: pnpm
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run Web App Tests
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build web package
working-directory: packages/web
run: bun run test
run: pnpm run build
- name: Build web package
working-directory: packages/web
run: pnpm run build
- name: Create Web App Release Archive
working-directory: packages/web
run: bun run package
run: pnpm run package
- name: Upload Web App Archive
- name: Upload Web App Archive (artifact)
uses: actions/upload-artifact@v4
with:
name: web-build
name: web-build-${{ steps.meta.outputs.tag }}
if-no-files-found: error
path: packages/web/dist/build.tar
- name: Attach Web Archive to GitHub Release
run: gh release upload ${{ github.event.release.tag_name }} packages/web/dist/build.tar
if: ${{ github.event_name == 'release' || inputs.attach_to_release == true }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ steps.meta.outputs.tag }}"
else
if [ -z "${{ inputs.tag_name }}" ]; then
echo "attach_to_release requested but no tag_name provided." >&2
exit 1
fi
TAG="${{ inputs.tag_name }}"
fi
gh release upload "$TAG" packages/web/dist/build.tar --clobber
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Compute image tags
id: tags
shell: bash
run: |
if [ "${{ steps.meta.outputs.push_latest }}" = "true" ]; then
echo "list=latest, ${{ steps.meta.outputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "list=${{ steps.meta.outputs.tag }}" >> "$GITHUB_OUTPUT"
fi
TAGS="latest, ${{ steps.meta.outputs.tag }}"
else
TAGS="${{ steps.meta.outputs.tag }}"
fi
echo "list=$TAGS" >> "$GITHUB_OUTPUT"
echo "Using image tags: $TAGS"
- name: Build Container Image
id: build-container
uses: redhat-actions/buildah-build@v2
with:
containerfiles: |
./infra/Containerfile
image: ghcr.io/${{ github.repository }}
tags: latest, ${{ github.event.release.tag_name }}
./packages/web/infra/Containerfile
image: ${{ env.REGISTRY_IMAGE }}
tags: ${{ steps.tags.outputs.list }}
oci: true
platforms: linux/amd64, linux/arm64

View File

@@ -7,44 +7,67 @@ on:
permissions:
contents: write
concurrency:
group: update-stable-${{ github.run_id }}
cancel-in-progress: true
jobs:
update-stable-branch:
name: Update Stable Branch from Main
name: Update stable from latest release source
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # need full history for reset/push
- name: Configure Git
- name: Configure Git author
run: |
git config user.name "GitHub Actions Bot"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Fetch latest main and stable branches
- name: Determine source ref & SHA
id: meta
shell: bash
run: |
git fetch origin main:main
git fetch origin stable:stable || echo "Stable branch not found remotely, will create."
- name: Get latest main commit SHA
id: get_main_sha
run: echo "MAIN_SHA=$(git rev-parse main)" >> $GITHUB_ENV
- name: Check out stable branch
run: |
if git show-ref --verify --quiet refs/heads/stable; then
git checkout stable
git pull origin stable # Sync with remote stable if it exists
else
echo "Creating local stable branch based on main HEAD."
git checkout -b stable ${{ env.MAIN_SHA }}
set -euo pipefail
SRC="${{ github.event.release.target_commitish }}"
if [ -z "$SRC" ] || ! git ls-remote --exit-code origin "refs/heads/$SRC" >/dev/null 2>&1; then
# Fallback to main if target_commitish is empty or not a branch
SRC="main"
fi
- name: Reset stable branch to latest main
run: git reset --hard ${{ env.MAIN_SHA }}
echo "Using source branch: $SRC"
git fetch origin "$SRC":"refs/remotes/origin/$SRC" --prune
SHA="$(git rev-parse "origin/$SRC")"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "src=$SRC" >> "$GITHUB_OUTPUT"
- name: Force push stable branch
run: git push origin stable --force
- name: Prepare local stable branch
shell: bash
run: |
set -euo pipefail
# Ensure we have the remote stable ref if it exists
git fetch origin stable:refs/remotes/origin/stable || true
if git show-ref --verify --quiet refs/heads/stable; then
echo "Local stable exists."
elif git show-ref --verify --quiet refs/remotes/origin/stable; then
echo "Creating local stable tracking branch from remote."
git checkout -b stable --track origin/stable
else
echo "Creating new local stable branch at source SHA."
git checkout -b stable "${{ steps.meta.outputs.sha }}"
fi
- name: Reset stable to source SHA
run: |
git checkout stable
git reset --hard "${{ steps.meta.outputs.sha }}"
git status --short --branch
- name: Push stable (force-with-lease)
run: |
# Safer than --force; refuses if remote moved unexpectedly
git push origin stable --force-with-lease

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ __screenshots__*
*.diff
npm/
.idea
**/LICENSE

View File

@@ -1,3 +1,3 @@
{
"recommendations": ["bradlc.vscode-tailwindcss", "denoland.vscode-deno"]
"recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"]
}

View File

@@ -2,5 +2,8 @@
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
}
},
"search.exclude": {
"**/i18n/locales/*-*/**": true,
},
}

View File

@@ -1,5 +1,5 @@
{
"name": "meshtastic-web",
"name": "@meshtastic/web",
"version": "2.7.0-0",
"type": "module",
"description": "Meshtastic web client monorepo",
@@ -25,7 +25,8 @@
"check:fix": "biome check --write",
"build:all": "pnpm run --filter '*' build",
"clean:all": "pnpm run --filter '*' clean",
"publish:packages": "pnpm run --filter 'packages/transport-* packages/core' build"
"publish:packages": "pnpm run build --filter 'packages/transport-*'",
"test": "vitest"
},
"dependencies": {
"@bufbuild/protobuf": "^2.6.1",
@@ -37,6 +38,15 @@
"@types/node": "^22.16.4",
"biome": "^0.3.3",
"tsdown": "^0.13.4",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},
"pnpm": {
"onlyBuiltDependencies": [
"@tailwindcss/oxide",
"core-js",
"esbuild",
"simple-git-hooks"
]
}
}

View File

@@ -1,27 +1,34 @@
{
"name": "@meshtastic/core",
"version": "2.6.6",
"version": "2.6.6-1",
"description": "Core functionalities for Meshtastic web applications.",
"exports": {
".": "./mod.ts"
},
"type": "module",
"main": "./dist/mod.mjs",
"module": "./dist/mod.mjs",
"types": "./dist/mod.d.mts",
"license": "GPL-3.0-only",
"tsdown": {
"entry": ["mod.ts"],
"entry": "mod.ts",
"dts": true,
"format": ["esm"],
"splitting": false,
"clean": true
},
"files": [
"package.json",
"README.md",
"LICENSE",
"dist"
],
"scripts": {
"preinstall": "npx only-allow pnpm",
"prepack": "cp ../../LICENSE ./LICENSE",
"clean": "rm -rf dist LICENSE",
"build:npm": "tsdown",
"publish:npm": "pnpm clean && pnpm build:npm && pnpm publish --access public",
"publish:npm": "pnpm clean && pnpm build:npm && pnpm publish --access public --no-git-checks",
"prepare:jsr": "rm -rf dist && pnpm dlx pkg-to-jsr",
"publish:jsr": "pnpm run prepack && pnpm prepare:jsr && deno publish --allow-dirty --no-check"
},

View File

@@ -626,7 +626,7 @@ export class MeshDevice {
public async reboot(time: number): Promise<number> {
this.log.debug(
Emitter[Emitter.Reboot],
`🔌 Rebooting node ${time > 0 ? "now" : `in ${time} seconds`}`,
`🔌 Rebooting node ${time === 0 ? "now" : `in ${time} seconds`}`,
);
const reboot = create(Protobuf.Admin.AdminMessageSchema, {
@@ -649,7 +649,7 @@ export class MeshDevice {
public async rebootOta(time: number): Promise<number> {
this.log.debug(
Emitter[Emitter.RebootOta],
`🔌 Rebooting into OTA mode ${time > 0 ? "now" : `in ${time} seconds`}`,
`🔌 Rebooting into OTA mode ${time === 0 ? "now" : `in ${time} seconds`}`,
);
const rebootOta = create(Protobuf.Admin.AdminMessageSchema, {

View File

@@ -330,6 +330,15 @@ export class EventSystem {
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing a ClientNotification packet has been
* received from device
*
* @event onClientNotificationPacket
*/
public readonly onClientNotificationPacket: SimpleEventDispatcher<Protobuf.Mesh.ClientNotification> =
new SimpleEventDispatcher<Protobuf.Mesh.ClientNotification>();
/**
* Fires when the devices connection or configuration status changes
*

View File

@@ -209,6 +209,18 @@ export const decodePacket = (device: MeshDevice) =>
break;
}
case "clientNotification": {
device.log.trace(
Types.Emitter[Types.Emitter.HandleFromRadio],
`📣 Received ClientNotification: ${decodedMessage.payloadVariant.value.message}`,
);
device.events.onClientNotificationPacket.dispatch(
decodedMessage.payloadVariant.value,
);
break;
}
default: {
device.log.warn(
Types.Emitter[Types.Emitter.HandleFromRadio],

View File

@@ -8,8 +8,20 @@
"main": "./dist/mod.mjs",
"module": "./dist/mod.mjs",
"types": "./dist/mod.d.mts",
"files": ["dist/*", "mod.ts", "README.md", "../../LICENSE"],
"license": "GPL-3.0-only",
"tsdown": {
"entry": "mod.ts",
"dts": true,
"format": ["esm"],
"splitting": false,
"clean": true
},
"files": [
"package.json",
"README.md",
"LICENSE",
"dist"
],
"scripts": {
"preinstall": "npx only-allow pnpm",
"prepack": "cp ../../LICENSE ./LICENSE",
@@ -18,5 +30,8 @@
"publish:npm": "pnpm clean && pnpm build:npm && pnpm publish --access public",
"prepare:jsr": "rm -rf dist && pnpm dlx pkg-to-jsr",
"publish:jsr": "pnpm run prepack && pnpm prepare:jsr && deno publish --allow-dirty --no-check"
},
"dependencies": {
"@meshtastic/core": "workspace:*"
}
}

View File

@@ -1,32 +1,37 @@
{
"name": "@meshtastic/transport-http",
"version": "0.2.3",
"version": "0.2.3-2",
"description": "A transport layer for Meshtastic applications using HTTP.",
"exports": {".": "./mod.ts"},
"main": "./dist/mod.mjs",
"module": "./dist/mod.mjs",
"types": "./dist/mod.d.mts",
"license": "GPL-3.0-only",
"tsdown": {
"entry": ["mod.ts"],
"dts": true,
"format": ["esm"],
"splitting": false,
"clean": true
},
"files": [
"type": "module",
"files": [
"package.json",
"README.md",
"LICENSE",
"dist"
],
"main": "./dist/mod.mjs",
"module": "./dist/mod.mjs",
"types": "./dist/mod.d.mts",
"license": "GPL-3.0-only",
"tsdown": {
"entry": "mod.ts",
"dts": true,
"format": ["esm"],
"splitting": false,
"clean": true
},
"scripts": {
"preinstall": "npx only-allow pnpm",
"prepack": "cp ../../LICENSE ./LICENSE",
"clean": "rm -rf dist LICENSE",
"build:npm": "tsdown",
"publish:npm": "pnpm clean && pnpm build:npm && pnpm publish --access public",
"publish:npm": "pnpm clean && pnpm build:npm && pnpm publish --access public --no-git-checks",
"prepare:jsr": "rm -rf dist && pnpm dlx pkg-to-jsr",
"publish:jsr": "pnpm run prepack && pnpm prepare:jsr && deno publish --allow-dirty --no-check"
},
"dependencies": {
"@meshtastic/core": "workspace:*"
}
}

View File

@@ -0,0 +1,28 @@
# @meshtastic/transport-node-serial
[![JSR](https://jsr.io/badges/@meshtastic/transport-node-serial)](https://jsr.io/@meshtastic/transport-node-serial)
[![CI](https://img.shields.io/github/actions/workflow/status/meshtastic/js/ci.yml?branch=master&label=actions&logo=github&color=yellow)](https://github.com/meshtastic/js/actions/workflows/ci.yml)
[![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/meshtastic.js)](https://cla-assistant.io/meshtastic/meshtastic.js)
[![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/)
[![Vercel](https://img.shields.io/static/v1?label=Powered%20by&message=Vercel&style=flat&logo=vercel&color=000000)](https://vercel.com?utm_source=meshtastic&utm_campaign=oss)
## Overview
`@meshtastic/transport-noden-node` Provides Serial transport (Node) for Meshtastic
devices. Installation instructions are available at
[JSR](https://jsr.io/@meshtastic/transport-node-serial)
[NPM](https://www.npmjs.com/package/@meshtastic/transport-node-serial)
## Usage
```ts
import { MeshDevice } from "@meshtastic/core";
import { TransportNodeSerial } from "@meshtastic/transport-node-serial";
const transport = await TransportNodeSerial.create("/dev/cu.usbserial-0001");
const device = new MeshDevice(transport);
```
## Stats
![Alt](https://repobeats.axiom.co/api/embed/5330641586e92a2ec84676fedb98f6d4a7b25d69.svg "Repobeats analytics image")

View File

@@ -0,0 +1 @@
export { TransportNodeSerial } from "./src/transport.ts";

View File

@@ -0,0 +1,39 @@
{
"name": "@meshtastic/transport-node-serial",
"version": "0.0.1",
"description": "NodeJS-specific serial transport layer for Meshtastic web applications.",
"exports": {
".": "./dist/mod.mjs"
},
"main": "./dist/mod.mjs",
"module": "./dist/mod.mjs",
"types": "./dist/mod.d.mts",
"license": "GPL-3.0-only",
"tsdown": {
"entry": "mod.ts",
"dts": true,
"format": ["esm"],
"splitting": false,
"clean": true
},
"files": [
"package.json",
"README.md",
"LICENSE",
"dist"
],
"scripts": {
"preinstall": "npx only-allow pnpm",
"prepack": "cp ../../LICENSE ./LICENSE",
"clean": "rm -rf dist LICENSE",
"build:npm": "tsdown",
"publish:npm": "pnpm clean && pnpm build:npm && pnpm publish --access public",
"prepare:jsr": "rm -rf dist && pnpm dlx pkg-to-jsr",
"publish:jsr": "pnpm run prepack && pnpm prepare:jsr && deno publish --allow-dirty --no-check"
},
"dependencies": {
"@meshtastic/core": "workspace:*",
"serialport": "^13.0.0"
}
}

View File

@@ -0,0 +1,87 @@
import { Readable, Writable } from "node:stream";
import type { Types } from "@meshtastic/core";
import { Utils } from "@meshtastic/core";
import { SerialPort } from "serialport";
export class TransportNodeSerial implements Types.Transport {
private readonly _toDevice: WritableStream<Uint8Array>;
private readonly _fromDevice: ReadableStream<Types.DeviceOutput>;
private port: SerialPort | undefined;
/**
* Creates and connects a new TransportNode instance.
* @param path - Path to the serial device
* @param baudRate - The port number for the TCP connection (defaults to 4403).
* @returns A promise that resolves with a connected TransportNode instance.
*/
public static create(path: string, baudRate = 115200): Promise<TransportNodeSerial> {
return new Promise((resolve, reject) => {
const port = new SerialPort({
path,
baudRate,
autoOpen: true,
});
const onError = (err: Error) => {
port.close();
reject(err);
};
port.once("error", onError);
port.on("open", () => {
port.removeListener("error", onError);
resolve(new TransportNodeSerial(port));
});
});
}
/**
* Constructs a new TransportNode.
* @param port - An active Node.js SerialPort connection.
*/
constructor(port: SerialPort) {
this.port = port;
this.port.on("error", (err) => {
console.error("Serial port connection error:", err);
});
const fromDeviceSource = Readable.toWeb(
port,
) as ReadableStream<Uint8Array>;
this._fromDevice = fromDeviceSource.pipeThrough(Utils.fromDeviceStream());
// Stream for data going FROM the application TO the Meshtastic device.
const toDeviceTransform = Utils.toDeviceStream;
this._toDevice = toDeviceTransform.writable;
// The readable end of the transform is then piped to the Node.js SerialPort connection.
// A similar assertion is needed here because `Writable.toWeb` also returns
// a generically typed stream (`WritableStream<any>`).
toDeviceTransform.readable
.pipeTo(Writable.toWeb(port) as WritableStream<Uint8Array>)
.catch((err) => {
console.error("Error piping data to serial port:", err);
this.port.close(err as Error);
});
}
/**
* The WritableStream to send data to the Meshtastic device.
*/
public get toDevice(): WritableStream<Uint8Array> {
return this._toDevice;
}
/**
* The ReadableStream to receive data from the Meshtastic device.
*/
public get fromDevice(): ReadableStream<Types.DeviceOutput> {
return this._fromDevice;
}
disconnect() {
this.port.close();
this.port = undefined;
return Promise.resolve();
}
}

View File

@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"target": "ES2020",
"declaration": true,
"outDir": "./dist",
"moduleResolution": "bundler",
"emitDeclarationOnly": false,
"esModuleInterop": true,
},
"include": ["src"]
}

View File

@@ -1,17 +1,18 @@
{
"name": "@meshtastic/transport-node",
"version": "0.0.1",
"version": "0.0.1-1",
"description": "NodeJS-specific transport layer for Meshtastic web applications.",
"exports": {
".": "./mod.ts"
},
"type": "module",
"main": "./dist/mod.mjs",
"module": "./dist/mod.mjs",
"types": "./dist/mod.d.mts",
"license": "GPL-3.0-only",
"tsdown": {
"entry": ["mod.ts"],
"entry": "mod.ts",
"dts": true,
"format": ["esm"],
"splitting": false,
@@ -28,8 +29,11 @@
"prepack": "cp ../../LICENSE ./LICENSE",
"clean": "rm -rf dist LICENSE",
"build:npm": "tsdown",
"publish:npm": "pnpm clean && pnpm build:npm && pnpm publish --access public",
"publish:npm": "pnpm clean && pnpm build:npm && pnpm publish --access public --no-git-checks",
"prepare:jsr": "rm -rf dist && pnpm dlx pkg-to-jsr",
"publish:jsr": "pnpm run prepack && pnpm prepare:jsr && deno publish --allow-dirty --no-check"
},
"dependencies": {
"@meshtastic/core": "workspace:*"
}
}

View File

@@ -1,17 +1,23 @@
{
"name": "@meshtastic/transport-web-bluetooth",
"version": "0.1.4",
"description": "A transport layer for Meshtastic applications using Web Bluetooth.",
"exports": {
".": "./mod.ts"
},
"version": "0.1.4-1",
"description": "A transport layer for Meshtastic applications using Web Bluetooth.",
"exports": {
".": "./mod.ts"
},
"type": "module",
"main": "./dist/mod.mjs",
"module": "./dist/mod.mjs",
"types": "./dist/mod.d.mts",
"files": ["dist/*", "mod.ts", "README.md", "../../LICENSE"],
"files": [
"package.json",
"README.md",
"LICENSE",
"dist"
],
"license": "GPL-3.0-only",
"tsdown": {
"entry": ["mod.ts"],
"entry": "mod.ts",
"dts": true,
"format": ["esm"],
"splitting": false,
@@ -22,12 +28,12 @@
"prepack": "cp ../../LICENSE ./LICENSE",
"clean": "rm -rf dist LICENSE",
"build:npm": "tsdown",
"publish:npm": "pnpm clean && pnpm build:npm && pnpm publish --access public",
"publish:npm": "pnpm clean && pnpm build:npm && pnpm publish --access public --no-git-checks",
"prepare:jsr": "rm -rf dist && pnpm dlx pkg-to-jsr",
"publish:jsr": "pnpm run prepack && pnpm prepare:jsr && deno publish --allow-dirty --no-check"
},
"dependencies": {
"@types/web-bluetooth": "npm:@types/web-bluetooth@^0.0.20"
}
"dependencies": {
"@types/web-bluetooth": "npm:@types/web-bluetooth@^0.0.20",
"@meshtastic/core": "workspace:*"
}
}

View File

@@ -1,17 +1,23 @@
{
"name": "@meshtastic/transport-web-serial",
"version": "0.2.3",
"version": "0.2.3-1",
"description": "A transport layer for Meshtastic applications using Web Serial API.",
"exports": {
".": "./mod.ts"
},
"type": "module",
"main": "./dist/mod.mjs",
"module": "./dist/mod.mjs",
"types": "./dist/mod.d.mts",
"files": ["dist/*", "mod.ts", "README.md", "../../LICENSE"],
"files": [
"package.json",
"README.md",
"LICENSE",
"dist"
],
"license": "GPL-3.0-only",
"tsdown": {
"entry": ["mod.ts"],
"entry": "mod.ts",
"dts": true,
"format": ["esm"],
"splitting": false,
@@ -22,11 +28,12 @@
"prepack": "cp ../../LICENSE ./LICENSE",
"clean": "rm -rf dist LICENSE",
"build:npm": "tsdown",
"publish:npm": "pnpm clean && pnpm build:npm && pnpm publish --access public",
"publish:npm": "pnpm clean && pnpm build:npm && pnpm publish --access public --no-git-checks",
"prepare:jsr": "rm -rf dist && pnpm dlx pkg-to-jsr",
"publish:jsr": "pnpm run prepack && pnpm prepare:jsr && deno publish --allow-dirty --no-check"
},
"dependencies": {
"@types/w3c-web-serial": "npm:@types/w3c-web-serial@^1.0.7"
"@types/w3c-web-serial": "npm:@types/w3c-web-serial@^1.0.7",
"@meshtastic/core": "workspace:*"
}
}

View File

@@ -0,0 +1,150 @@
# Contributing to Meshtastic Web
Thank you for your interest in contributing to **Meshtastic Web**! 🎉
We welcome all contributions—whether its fixing a typo, improving documentation, adding new features, or reporting bugs. This document outlines how to get started and the conventions we follow.
---
## 📋 Code of Conduct
We follow the [Meshtastic Code of Conduct](https://meshtastic.org/docs/legal/conduct/).
Please make sure you are familiar with it before contributing.
---
## 🚀 Getting Started
Before making changes, please take some time to explore the repository and its monorepo structure.
Understanding how the packages are organized will make it much easier to contribute effectively.
[Meshtastic Web](https://github.com/meshtastic/web/)
### Prerequisites
- [Node.js](https://nodejs.org/) (v22 or later)
- [pnpm](https://pnpm.io/) (v10.14.x or later)
- Git
### Installation
Clone the repo and install dependencies:
```bash
git clone https://github.com/meshtastic/web.git meshtastic-web
cd meshtastic-web
pnpm install
```
### Development
Start the development server:
```bash
pnpm --filter @meshtastic/web dev
```
Once running, the site will be available at:
👉 **http://localhost:3000**
---
## 🗂 Repository Structure
Meshtastic Web uses a **monorepo** setup managed with **pnpm workspaces**:
```
/packages
├─ web # React frontend
├─ core # Shared types & logic
├─ transport-* # Transport layer packages
└─ ...other packages
```
---
## ✅ Contribution Workflow
1. **Fork the repo** and create your branch from `main`.
### Branch Naming
- Use [Conventional Commit](https://www.conventionalcommits.org/) style for your branch names:
```
feat/add-project-filter
fix/storage-service
chore/update-ci-cache
```
2. **Make your changes locally** and verify that the app runs as expected at `http://localhost:3000`.
3. **Commit your changes** with a descriptive commit message that follows the [Conventional Commits](https://www.conventionalcommits.org/) style.
4. **Open a Pull Request (PR)** from your fork's branch to the main repository's `main` branch on GitHub:
- Clearly describe the problem and solution.
- Reference related issues (e.g., `Fixes #123`).
- Keep PRs focused on a single feature or fix.
- Complete all fields in the PR template.
- Tag a **Meshtastic Web developer** in the PR for review.
5. **CI/CD**:
- Our GitHub Actions workflows handle builds, linting, and packaging automatically.
- All checks must pass before merge.
---
## 🌍 Internationalization (i18n)
Meshtastic Web supports multiple languages. If your changes introduce **new user-facing strings**:
- Add them to the **`en.json`** file.
- Do **not** hardcode English strings directly in components.
- This ensures they can be translated into other languages.
🔗 See these guides for more details:
- [i18n Developer Guide](https://github.com/meshtastic/web/blob/main/packages/web/CONTRIBUTING_I18N_DEVELOPER_GUIDE.md)
- [Translation Contribution Guide](https://github.com/meshtastic/web/blob/main/packages/web/CONTRIBUTING_TRANSLATIONS.md)
---
## 🧪 Testing
Tests are written with [Vitest](https://vitest.dev/).
Run all tests locally with:
```bash
pnpm --filter @meshtastic/web test
```
Please include tests for new features and bug fixes whenever possible.
---
## 📝 Commit Messages
We use **Conventional Commits**:
- `feat:` a new feature
- `fix:` a bug fix
- `docs:` documentation changes
- `chore:` maintenance, dependencies, build scripts
- `refactor:` code restructuring without feature changes
- `test:` adding or updating tests
- `ci:` CI/CD changes
Example:
```
feat: add toast notification system
fix: correct caching issue in storage service
```
---
## 💡 Tips for Contributors
- Keep PRs **small, focused, and atomic**.
- Discuss larger changes with the team on [Discord](https://discord.gg/meshtastic) before starting work.
- If unsure, open a draft PR for early feedback.
---
## 🙌 Community
Contributors are the heart of Meshtastic ❤️.
Join the conversation:
- [Discord](https://discord.gg/meshtastic)
- [GitHub Discussions](https://github.com/meshtastic/web/discussions)
---
## 📜 License
By contributing, you agree that your contributions will be licensed under the [GPL-3.0-only License](../../LICENSE).

View File

@@ -1,12 +1,12 @@
FROM nginx:1.27-alpine
RUN rm -r /usr/share/nginx/html \
&& mkdir -p /usr/share/nginx/html \
&& mkdir -p /etc/nginx/conf.d
&& mkdir -p /usr/share/nginx/html \
&& mkdir -p /etc/nginx/conf.d
WORKDIR /usr/share/nginx/html
ADD dist .
ADD ./dist .
COPY ./infra/default.conf /etc/nginx/conf.d/default.conf

View File

@@ -1,42 +1,51 @@
server {
listen 8080;
server_name localhost;
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html index.htm;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|jsx|mjs|ts|tsx|css|png|jpg|jpeg|gif|ico|webp|avif|svg|ttf|otf|woff|woff2|map)$ {
access_log off;
etag on;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
internal;
}
expires 3M;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location ~ /\.ht {
deny all;
}
location = /index.html {
etag on;
add_header Cache-Control "no-cache";
}
gzip on;
gzip_disable "msie6";
location / {
try_files $uri $uri/ /index.html;
}
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/xml+rss
font/ttf
font/otf
image/svg+xml;
error_page 500 502 503 504 /50x.html;
location = /50x.html { internal; }
location ~ /\.ht { deny all; }
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/xml+rss
font/ttf
font/otf
image/svg+xml;
}

View File

@@ -1,6 +1,6 @@
{
"name": "meshtastic-web",
"version": "2.7.0-0",
"version": "2.6.6-0",
"type": "module",
"description": "Meshtastic web client",
"license": "GPL-3.0-only",
@@ -22,6 +22,7 @@
"test": "vitest",
"ts:check": "bun run tsc --noEmit",
"preview": "vite preview",
"docker:build": "docker build -t meshtastic-web:latest -f ./infra/Containerfile .",
"generate:routes": "bun @tanstack/router-cli generate --outDir src/ routes --rootRoutePath /",
"package": "gzipper c -i html,js,css,png,ico,svg,json,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
},
@@ -29,9 +30,9 @@
"@bufbuild/protobuf": "^2.6.0",
"@hookform/resolvers": "^5.1.1",
"@meshtastic/core": "workspace:*",
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
"@meshtastic/transport-http": "workspace:*",
"@meshtastic/transport-web-bluetooth": "workspace:*",
"@meshtastic/transport-web-serial": "workspace:*",
"@noble/curves": "^1.9.2",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-checkbox": "^1.3.2",

View File

@@ -25,7 +25,7 @@
"save": "Запис",
"scanQr": "Сканиране на QR кода",
"traceRoute": "Trace Route",
"submit": "Submit"
"submit": "Изпращане"
},
"app": {
"title": "Meshtastic",

View File

@@ -18,22 +18,22 @@
"description": "Настройки за устройството",
"buttonPin": {
"description": "Button pin override",
"label": "Button Pin"
"label": "Пин за бутон"
},
"buzzerPin": {
"description": "Buzzer pin override",
"label": "Buzzer Pin"
"label": "Пин за зумер"
},
"disableTripleClick": {
"description": "Disable triple click",
"label": "Disable Triple Click"
"description": "Дезактивиране на трикратното щракване",
"label": "Дезактивиране на трикратното щракване"
},
"doubleTapAsButtonPress": {
"description": "Treat double tap as button press",
"label": "Double Tap as Button Press"
},
"ledHeartbeatDisabled": {
"description": "Disable default blinking LED",
"description": "Дезактивиране на мигащия светодиод по подразбиране",
"label": "LED Heartbeat Disabled"
},
"nodeInfoBroadcastInterval": {
@@ -138,7 +138,7 @@
"label": "Отместване на честотата"
},
"frequencySlot": {
"description": "LoRa frequency channel number",
"description": "Номер на LoRa честотен канал",
"label": "Честотен слот"
},
"hopLimit": {
@@ -277,7 +277,7 @@
},
"enablePin": {
"description": "GPS module enable pin override",
"label": "Enable Pin"
"label": "Активиране на пин"
},
"fixedPosition": {
"description": "Don't report GPS position, but a manually-specified one",
@@ -341,7 +341,7 @@
},
"ina219Address": {
"description": "Address of the INA219 battery monitor",
"label": "INA219 Address"
"label": "INA219 адрес"
},
"lightSleepDuration": {
"description": "How long the device will be in light sleep for",
@@ -353,7 +353,7 @@
},
"noConnectionBluetoothDisabled": {
"description": "If the device does not receive a Bluetooth connection, the BLE radio will be disabled after this long",
"label": "No Connection Bluetooth Disabled"
"label": "Няма връзка Bluetooth е дезактивиран"
},
"powerSavingEnabled": {
"description": "Select if powered from a low-current source (i.e. solar), to minimize power consumption as much as possible.",
@@ -390,10 +390,10 @@
},
"managed": {
"description": "If enabled, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless at least one suitable Remote Admin node has been setup, and the public key is stored in one of the fields above.",
"label": "Managed"
"label": "Управляван"
},
"privateKey": {
"description": "Used to create a shared key with a remote device",
"description": "Използва се за създаване на споделен ключ с отдалечено устройство",
"label": "Частен ключ"
},
"publicKey": {
@@ -418,7 +418,7 @@
},
"adminSettings": {
"description": "Настройки за Admin",
"label": "Admin Settings"
"label": "Администраторски настройки"
},
"loggingSettings": {
"description": "Settings for Logging",

View File

@@ -9,10 +9,10 @@
"shortName": "Кратко име",
"title": "Промяна на името на устройството",
"validation": {
"longNameMax": "Long name must not be more than 40 characters",
"shortNameMax": "Short name must not be more than 4 characters",
"longNameMin": "Long name must have at least 1 character",
"shortNameMin": "Short name must have at least 1 character"
"longNameMax": "Дългото име не трябва да е повече от 40 знака",
"shortNameMax": "Краткото име не трябва да е повече от 4 знака",
"longNameMin": "Дългото име трябва да съдържа поне 1 символ",
"shortNameMin": "Краткото име трябва да съдържа поне 1 символ"
}
},
"import": {
@@ -23,7 +23,7 @@
"channelPrefix": "Канал: ",
"channelSetUrl": "Channel Set/QR Code URL",
"channels": "Канали:",
"usePreset": "Use Preset?",
"usePreset": "Използване на предварително зададени настройки?",
"title": "Import Channel Set"
},
"locationResponse": {
@@ -68,11 +68,11 @@
"bluetoothConnection": {
"noDevicesPaired": "Все още няма сдвоени устройства.",
"newDeviceButton": "Ново устройство",
"connectionFailed": "Connection failed",
"deviceDisconnected": "Device disconnected",
"connectionFailed": "Връзката е неуспешна",
"deviceDisconnected": "Устройството не е свързано",
"unknownDevice": "Неизвестно устройство",
"errorLoadingDevices": "Error loading devices",
"unknownErrorLoadingDevices": "Unknown error loading devices"
"errorLoadingDevices": "Грешка при зареждане на устройствата",
"unknownErrorLoadingDevices": "Неизвестна грешка при зареждане на устройствата"
},
"validation": {
"requiresFeatures": "Този тип връзка изисква <0></0>. Моля, използвайте поддържан браузър, като Chrome или Edge.",
@@ -116,7 +116,7 @@
"pkiBackupReminder": {
"description": "Препоръчваме редовно да правите резервни копия на данните с вашите ключове. Искате ли да направите резервно копие сега?",
"title": "Напомняне за резервно копие",
"remindLaterPrefix": "Remind me in",
"remindLaterPrefix": "Напомни ми в",
"remindNever": "Никога не ми напомняй",
"backupNow": "Създаване на резервно копие сега"
},

View File

@@ -1,14 +1,14 @@
{
"page": {
"title": "Съобщения: {{chatName}}",
"placeholder": "Enter Message"
"placeholder": "Въведете съобщение"
},
"emptyState": {
"title": "Изберете чат",
"text": "Все още няма съобщения."
},
"selectChatPrompt": {
"text": "Select a channel or node to start messaging."
"text": "Изберете канал или възел, за да стартирате съобщения."
},
"sendMessage": {
"placeholder": "Въведете Вашето съобщение тук...",
@@ -24,16 +24,16 @@
"displayText": "Съобщението е доставено"
},
"failed": {
"label": "Message delivery failed",
"displayText": "Delivery failed"
"label": "Доставката на съобщението не е успешна",
"displayText": "Неуспешна доставка"
},
"unknown": {
"label": "Статусът на съобщението е неизвестен",
"displayText": "Неизвестно състояние"
},
"waiting": {
"label": "Sending message",
"displayText": "Waiting for delivery"
"label": "Изпращане на съобщение",
"displayText": "Чака доставка"
}
}
}

View File

@@ -45,8 +45,8 @@
"description": "Enable Codec 2 audio encoding"
},
"pttPin": {
"label": "PTT Pin",
"description": "GPIO pin to use for PTT"
"label": "Пин за РТТ",
"description": "GPIO пин, който да се използва за PTT"
},
"bitrate": {
"label": "Bitrate",

View File

@@ -28,10 +28,10 @@
"label": "Височина"
},
"channelUtil": {
"label": "Channel Util"
"label": "Използване на канала"
},
"airtimeUtil": {
"label": "Airtime Util"
"label": "Използване на ефирно време"
}
},
"nodesTable": {

View File

@@ -136,7 +136,7 @@
"label": "Филтър"
},
"advanced": {
"label": "Advanced"
"label": "Разширени"
},
"clearInput": {
"label": "Clear input"
@@ -149,7 +149,7 @@
"placeholder": "Meshtastic 1234"
},
"airtimeUtilization": {
"label": "Airtime Utilization (%)"
"label": "Използване на ефира (%)"
},
"batteryLevel": {
"label": "Ниво на батерията (%)",
@@ -179,16 +179,16 @@
"label": "Любими"
},
"hide": {
"label": "Hide"
"label": "Скриване"
},
"showOnly": {
"label": "Show Only"
"label": "Показване само"
},
"viaMqtt": {
"label": "Свързан чрез MQTT"
},
"hopsUnknown": {
"label": "Unknown number of hops"
"label": "Неизвестен брой хопове"
},
"showUnheard": {
"label": "Never heard"

View File

@@ -33,9 +33,9 @@
"qrGenerator": "Generator",
"qrImport": "Import",
"scheduleShutdown": "Schedule Shutdown",
"scheduleReboot": "Schedule Reboot",
"rebootToOtaMode": "Reboot To OTA Mode",
"scheduleReboot": "Reboot Device",
"resetNodeDb": "Reset Node DB",
"dfuMode": "Enter DFU Mode",
"factoryResetDevice": "Factory Reset Device",
"factoryResetConfig": "Factory Reset Config",
"disconnect": "Disconnect"

View File

@@ -17,7 +17,6 @@
"now": "Now",
"ok": "OK",
"print": "Print",
"rebootOtaNow": "Reboot to OTA Mode Now",
"remove": "Remove",
"requestNewKeys": "Request New Keys",
"requestPosition": "Request Position",
@@ -108,5 +107,7 @@
"managed": "At least one admin key is requred if the node is managed.",
"key": "Key is required."
}
}
},
"yes": "Yes",
"no": "No"
}

View File

@@ -76,8 +76,9 @@
"unknownErrorLoadingDevices": "Unknown error loading devices"
},
"validation": {
"requiresFeatures": "This connection type requires <0></0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. Please connect using HTTPS or localhost."
}
},
@@ -93,7 +94,7 @@
"deviceMetrics": "Device Metrics:",
"hardware": "Hardware: ",
"lastHeard": "Last Heard: ",
"nodeHexPrefix": "Node Hex: !",
"nodeHexPrefix": "Node Hex: ",
"nodeNumber": "Node Number: ",
"position": "Position:",
"role": "Role: ",
@@ -102,7 +103,12 @@
"title": "Node Details for {{identifier}}",
"ignoreNode": "Ignore node",
"removeNode": "Remove node",
"unignoreNode": "Unignore node"
"unignoreNode": "Unignore node",
"security": "Security:",
"publicKey": "Public Key: ",
"messageable": "Messageable: ",
"KeyManuallyVerifiedTrue": "Public Key has been manually verified",
"KeyManuallyVerifiedFalse": "Public Key is not manually verified"
},
"pkiBackup": {
"loseKeysWarning": "If you lose your keys, you will need to reset your device.",
@@ -132,15 +138,15 @@
"sharableUrl": "Sharable URL",
"title": "Generate QR Code"
},
"rebootOta": {
"title": "Schedule Reboot",
"description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.",
"enterDelay": "Enter delay (sec)",
"scheduled": "Reboot has been scheduled"
},
"reboot": {
"title": "Schedule Reboot",
"description": "Reboot the connected node after x minutes."
"title": "Reboot device",
"description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.",
"ota": "Reboot into OTA mode",
"enterDelay": "Enter delay",
"scheduled": "Reboot has been scheduled",
"schedule": "Schedule reboot",
"now": "Reboot now",
"cancel": "Cancel scheduled reboot"
},
"refreshKeys": {
"description": {
@@ -179,5 +185,10 @@
"confirmUnderstanding": "Yes, I know what I'm doing",
"title": "Are you sure?",
"description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration."
},
"clientNotification": {
"title": "Client Notification",
"TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds",
"Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated."
}
}

View File

@@ -7,7 +7,7 @@
"label": "No Public Key"
},
"directMessage": {
"label": "Direct Message {{shortName}}"
"label": "Direk Mesaj {{shortName}}"
},
"favorite": {
"label": "Favori",
@@ -50,12 +50,12 @@
"viaMqtt": ", via MQTT"
},
"lastHeardStatus": {
"never": "Never"
"never": "Hiçbir zaman"
}
},
"actions": {
"added": "Added",
"removed": "Removed",
"added": "Eklendi",
"removed": "Kaldırıldı",
"ignoreNode": "Ignore Node",
"unignoreNode": "Unignore Node",
"requestPosition": "Request Position"

View File

@@ -6,9 +6,7 @@ import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import Footer from "@components/UI/Footer.tsx";
import { useTheme } from "@core/hooks/useTheme.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
import { SidebarProvider, useAppStore, useDeviceStore } from "@core/stores";
import { Dashboard } from "@pages/Dashboard/index.tsx";
import { Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";

View File

@@ -1,5 +1,4 @@
import type { Device } from "@core/stores/deviceStore.ts";
import { DeviceContext } from "@core/stores/deviceStore.ts";
import { type Device, DeviceContext } from "@core/stores";
import type { ReactNode } from "react";
export interface DeviceWrapperProps {

View File

@@ -8,8 +8,7 @@ import {
CommandList,
} from "@components/UI/Command.tsx";
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
import { useAppStore, useDevice, useDeviceStore } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { useNavigate } from "@tanstack/react-router";
import { useCommandState } from "cmdk";
@@ -20,6 +19,7 @@ import {
CloudOff,
EraserIcon,
FactoryIcon,
HardDriveUpload,
LayersIcon,
LinkIcon,
type LucideIcon,
@@ -190,10 +190,10 @@ export const CommandPalette = () => {
},
},
{
label: t("contextual.command.rebootToOtaMode"),
icon: RefreshCwIcon,
label: t("contextual.command.dfuMode"),
icon: HardDriveUpload,
action() {
setDialogOpen("rebootOTA", true);
connection?.enterDfuMode()
},
},
{

View File

@@ -0,0 +1,72 @@
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useDevice } from "@core/stores";
import { useTranslation } from "react-i18next";
export interface ClientNotificationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const ClientNotificationDialog = ({
open,
onOpenChange,
}: ClientNotificationDialogProps) => {
const { t } = useTranslation("dialog");
const { getClientNotification, removeClientNotification } = useDevice();
const localOnOpenChange = (open: boolean) => {
removeClientNotification(0);
if (!getClientNotification(0)) {
onOpenChange(open);
}
};
const dialogContent = (() => {
if (!getClientNotification(0)) {
return;
}
switch (getClientNotification(0)?.payloadVariant.case) {
// TODO: Add KeyVerification logic
/*case "keyVerificationNumberInform":
return <></>;
case "keyVerificationNumberRequest":
return <></>;
case "keyVerificationFinal":
return <></>;
case "duplicatedPublicKey":
return <></>;
case "lowEntropyKey":
return <></>;*/
default:
return (
<DialogHeader>
<DialogTitle>{t("clientNotification.title")}</DialogTitle>
<DialogDescription>
{t([
`clientNotification.${getClientNotification(0)?.message}`,
getClientNotification(0)?.message ?? "",
])}
</DialogDescription>
</DialogHeader>
);
}
})();
return (
<Dialog open={open} onOpenChange={localOnOpenChange}>
<DialogContent>
<DialogClose />
{dialogContent}
</DialogContent>
</Dialog>
);
};

View File

@@ -2,9 +2,9 @@ import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/De
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Ensure the path is correct for import
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useMessageStore } from "@core/stores";
vi.mock("@core/stores/messageStore", () => ({
vi.mock("@core/stores", () => ({
useMessageStore: vi.fn(() => ({
deleteAllMessages: vi.fn(),
})),

View File

@@ -8,9 +8,9 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useMessageStore } from "@core/stores";
import { AlertTriangleIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface DeleteMessagesDialogProps {
open: boolean;

View File

@@ -10,7 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { zodResolver } from "@hookform/resolvers/zod";
import { Protobuf } from "@meshtastic/core";
import { useForm } from "react-hook-form";

View File

@@ -1,3 +1,4 @@
import { ClientNotificationDialog } from "@components/Dialog/ClientNotificationDialog/ClientNotificationDialog.tsx";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
@@ -5,12 +6,11 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice();
@@ -79,18 +79,18 @@ export const DialogManager = () => {
setDialogOpen("refreshKeys", open);
}}
/>
<RebootOTADialog
open={dialog.rebootOTA}
onOpenChange={(open) => {
setDialogOpen("rebootOTA", open);
}}
/>
<DeleteMessagesDialog
open={dialog.deleteMessages}
onOpenChange={(open) => {
setDialogOpen("deleteMessages", open);
}}
/>
<ClientNotificationDialog
open={dialog.clientNotification}
onOpenChange={(open) => {
setDialogOpen("clientNotification", open);
}}
/>
</>
);
};

View File

@@ -12,7 +12,7 @@ import {
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { Protobuf } from "@meshtastic/core";
import { toByteArray } from "base64-js";
import { useEffect, useState } from "react";

View File

@@ -1,4 +1,4 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";

View File

@@ -27,12 +27,12 @@ import {
import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts";
import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts";
import { toast } from "@core/hooks/useToast.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore, useDevice } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useNavigate } from "@tanstack/react-router";
import { fromByteArray } from "base64-js";
import {
BellIcon,
BellOffIcon,
@@ -167,7 +167,8 @@ export const NodeDetailsDialog = ({
key: "batteryLevel",
label: t("nodeDetails.batteryLevel"),
value: node.deviceMetrics?.batteryLevel,
format: (val: number) => `${val.toFixed(2)}%`,
format: (val: number) =>
val === 101 ? t("batteryStatus.pluggedIn") : `${val.toFixed(2)}%`,
},
{
key: "voltage",
@@ -177,6 +178,9 @@ export const NodeDetailsDialog = ({
},
];
const sectionClassName =
"text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-4 rounded-lg mt-3";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent aria-describedby={undefined}>
@@ -192,7 +196,7 @@ export const NodeDetailsDialog = ({
</DialogTitle>
</DialogHeader>
<DialogFooter>
<div className="w-full">
<div className="w-full ">
<div className="flex flex-row flex-wrap space-y-1">
<Button
className="mr-1"
@@ -270,79 +274,134 @@ export const NodeDetailsDialog = ({
<p className="text-lg font-semibold">
{t("nodeDetails.details")}
</p>
<p>
{t("nodeDetails.nodeNumber")}
{node.num}
</p>
<p>
{t("nodeDetails.nodeHexPrefix")}
{numberToHexUnpadded(node.num)}
</p>
<p>
{t("nodeDetails.role")}
{Protobuf.Config.Config_DeviceConfig_Role[
node.user?.role ?? 0
].replace(/_/g, " ")}
</p>
<p>
{t("nodeDetails.lastHeard")}
{node.lastHeard === 0 ? (
t("nodesTable.lastHeardStatus.never", { ns: "nodes" })
) : (
<TimeAgo timestamp={node.lastHeard * 1000} />
)}
</p>
<p>
{t("nodeDetails.hardware")}
{(
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
t("unknown.shortName")
).replace(/_/g, " ")}
</p>
<table className="table-fixed w-full">
<tbody>
<tr>
<td>{t("nodeDetails.nodeNumber")}</td>
<td>{node.num}</td>
</tr>
<tr>
<td>{t("nodeDetails.nodeHexPrefix")}</td>
<td>!{numberToHexUnpadded(node.num)}</td>
</tr>
<tr>
<td>{t("nodeDetails.role")}</td>
<td>
{Protobuf.Config.Config_DeviceConfig_Role[
node.user?.role ?? 0
]?.replace(/_/g, " ")}
</td>
</tr>
<tr>
<td>{t("nodeDetails.lastHeard")}</td>
<td>
{node.lastHeard === 0 ? (
t("nodesTable.lastHeardStatus.never", {
ns: "nodes",
})
) : (
<TimeAgo timestamp={node.lastHeard * 1000} />
)}
</td>
</tr>
<tr>
<td>{t("nodeDetails.hardware")}</td>
<td>
{(
Protobuf.Mesh.HardwareModel[
node.user?.hwModel ?? 0
] ?? t("unknown.shortName")
).replace(/_/g, " ")}
</td>
</tr>
<tr>
<td>{t("nodeDetails.messageable")}</td>
<td>
{node.user?.isUnmessagable ? t("no") : t("yes")}
</td>
</tr>
</tbody>
</table>
</div>
<DeviceImage
className="h-45 w-45 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800"
className="w-40 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
"UNKNOWN"
}
/>
</div>
</div>
<div>
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<div className={sectionClassName}>
<p className="text-lg font-semibold">
{t("nodeDetails.security")}
</p>
<table className="table-auto w-full">
<tbody>
<tr>
<td className="pr-2">{t("nodeDetails.publicKey")}</td>
<td>
<pre className="text-xs pt-0.5">
{node.user?.publicKey &&
node.user?.publicKey.length > 0
? fromByteArray(node.user.publicKey)
: t("unknown.longName")}
</pre>
</td>
</tr>
<tr>
<td></td>
<td>
{node.isKeyManuallyVerified
? t("nodeDetails.KeyManuallyVerifiedTrue")
: t("nodeDetails.KeyManuallyVerifiedFalse")}
</td>
</tr>
</tbody>
</table>
</div>
<div className={sectionClassName}>
<p className="text-lg font-semibold">
{t("nodeDetails.position")}
</p>
{node.position ? (
<>
{node.position.latitudeI && node.position.longitudeI && (
<p>
{t("locationResponse.coordinates")}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
node.position.latitudeI / 1e7
}&mlon=${node.position.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{node.position.latitudeI / 1e7},{" "}
{node.position.longitudeI / 1e7}
</a>
</p>
)}
{node.position.altitude && (
<p>
{t("locationResponse.altitude")}
{node.position.altitude}
{t("unit.meter.one")}
</p>
)}
</>
<table className="table-auto w-full">
<tbody>
{node.position.latitudeI && node.position.longitudeI && (
<tr>
<td>{t("locationResponse.coordinates")}</td>
<td>
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
node.position.latitudeI / 1e7
}&mlon=${node.position.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{node.position.latitudeI / 1e7},{" "}
{node.position.longitudeI / 1e7}
</a>
</td>
</tr>
)}
{node.position.altitude && (
<tr>
<td>{t("locationResponse.altitude")}</td>
<td>
{node.position.altitude}
{t("unit.meter.suffix")}
</td>
</tr>
)}
</tbody>
</table>
) : (
<p>{t("unknown.shortName")}</p>
<p>{t("unknown.longName")}</p>
)}
<Button
onClick={handleRequestPosition}
@@ -355,28 +414,37 @@ export const NodeDetailsDialog = ({
</div>
{node.deviceMetrics && (
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<div className={sectionClassName}>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{t("nodeDetails.deviceMetrics")}
</p>
{deviceMetricsMap
.filter((metric) => metric.value !== undefined)
.map((metric) => (
<p key={metric.key}>
{metric.label}: {metric.format(metric?.value ?? 0)}
</p>
))}
{node.deviceMetrics.uptimeSeconds && (
<p>
{t("nodeDetails.uptime")}
<Uptime seconds={node.deviceMetrics.uptimeSeconds} />
</p>
)}
<table className="table-fixed w-full">
<tbody>
{deviceMetricsMap
.filter((metric) => metric.value !== undefined)
.map((metric) => (
<tr key={metric.key}>
<td>{metric.label}: </td>
<td>{metric.format(metric?.value ?? 0)}</td>
</tr>
))}
{node.deviceMetrics.uptimeSeconds && (
<tr>
<td>{t("nodeDetails.uptime")}</td>
<td>
<Uptime
seconds={node.deviceMetrics.uptimeSeconds}
/>
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 rounded-lg mt-3">
<Accordion className="AccordionRoot" type="single" collapsible>
<AccordionItem className="AccordionItem" value="item-1">
<AccordionTrigger>

View File

@@ -11,7 +11,7 @@ import { fromByteArray } from "base64-js";
import { DownloadIcon, PrinterIcon } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { useDevice } from "../../core/stores/deviceStore.ts";
import { useDevice } from "../../core/stores";
import { Button } from "../UI/Button.tsx";
export interface PkiBackupDialogProps {

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import type {
ButtonHTMLAttributes,
ClassAttributes,
@@ -7,14 +7,19 @@ import type {
} from "react";
import type { JSX } from "react/jsx-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RebootOTADialog } from "./RebootOTADialog.tsx";
import { RebootDialog } from "./RebootDialog.tsx";
const rebootMock = vi.fn();
const rebootOtaMock = vi.fn();
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
let mockConnection: {
rebootOta: (delay: number) => void,
reboot: (delay: number) => void
} | undefined = {
reboot: rebootMock,
rebootOta: rebootOtaMock,
};
vi.mock("@core/stores/deviceStore.ts", () => ({
vi.mock("@core/stores", () => ({
useDevice: () => ({
connection: mockConnection,
}),
@@ -61,7 +66,7 @@ vi.mock("@components/UI/Dialog.tsx", () => {
};
});
describe("RebootOTADialog", () => {
describe("RebootDialog", () => {
beforeEach(() => {
vi.useFakeTimers();
rebootOtaMock.mockClear();
@@ -72,44 +77,68 @@ describe("RebootOTADialog", () => {
});
it("renders dialog with default input value", () => {
render(<RebootOTADialog open onOpenChange={() => {}} />);
render(<RebootDialog open onOpenChange={() => {}} />);
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
expect(
screen.getByRole("heading", { name: /schedule reboot/i, level: 1 }),
screen.getByRole("heading", { name: /reboot device/i, level: 1 }),
).toBeInTheDocument();
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /reboot now/i })).toBeInTheDocument();
});
it("calls correct reboot function based on OTA checkbox state", () => {
render(<RebootDialog open onOpenChange={() => {}} />);
// Schedule non-OTA reboot
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(rebootMock).toHaveBeenCalledWith(5);
expect(rebootOtaMock).not.toHaveBeenCalled();
rebootMock.mockClear();
rebootOtaMock.mockClear();
// Cancel scheduled
fireEvent.click(screen.getByTestId("cancelRebootBtn"));
expect(rebootMock).toHaveBeenCalledWith(-1);
expect(rebootOtaMock).not.toHaveBeenCalled();
rebootMock.mockClear();
rebootOtaMock.mockClear();
// Schedule OTA reboot
fireEvent.click(screen.getByText(/reboot into ota mode/i));
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(rebootOtaMock).toHaveBeenCalledWith(5);
expect(rebootMock).not.toHaveBeenCalled();
});
it("schedules a reboot with delay and calls rebootOta", async () => {
const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
target: { value: "3" },
});
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(rebootMock).toHaveBeenCalledWith(3);
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
vi.advanceTimersByTime(3000);
await waitFor(() => {
expect(rebootOtaMock).toHaveBeenCalledWith(0);
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
it("triggers an instant reboot", async () => {
const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
fireEvent.click(screen.getByRole("button", { name: /reboot now/i }));
await waitFor(() => {
expect(rebootOtaMock).toHaveBeenCalledWith(5);
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
expect(rebootMock).toHaveBeenCalledWith(0);
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
it("does not call reboot if connection is undefined", async () => {
@@ -117,16 +146,30 @@ describe("RebootOTADialog", () => {
mockConnection = undefined;
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
vi.advanceTimersByTime(5000);
await waitFor(() => {
expect(rebootOtaMock).not.toHaveBeenCalled();
expect(onOpenChangeMock).not.toHaveBeenCalled();
});
expect(rebootMock).not.toHaveBeenCalled();
expect(rebootOtaMock).not.toHaveBeenCalled();
mockConnection = { rebootOta: rebootOtaMock };
mockConnection = { reboot: rebootMock, rebootOta: rebootOtaMock };
});
it("cancels a scheduled reboot and calls rebootOta with -1", async () => {
const onOpenChangeMock = vi.fn();
render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
target: { value: "4" },
});
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(rebootMock).toHaveBeenCalledWith(4);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(rebootMock).toHaveBeenCalledWith(-1);
expect(screen.queryByText(/reboot has been scheduled/i)).not.toBeInTheDocument();
});
});

View File

@@ -1,4 +1,5 @@
import { Button } from "@components/UI/Button.tsx";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
@@ -8,8 +9,10 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { RefreshCwIcon } from "lucide-react";
import { Label } from "@components/UI/Label.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { useDevice } from "@core/stores";
import { ClockIcon, OctagonXIcon, RefreshCwIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -18,11 +21,69 @@ export interface RebootDialogProps {
onOpenChange: (open: boolean) => void;
}
const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false);
const [isOTA, setIsOTA] = useState(false);
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString());
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>();
const [time, setTime] = useState<number>(5);
const handleReboot = (delay: number) => {
if (!connection) {
return;
}
if (isOTA) {
connection.rebootOta(delay);
} else {
connection.reboot(delay);
}
};
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) {
e.preventDefault();
return;
}
const val = e.target.value;
setInputValue(val);
const parsed = Number(val);
if (!Number.isNaN(parsed) && parsed > 0) {
setTime(parsed);
}
};
const handleRebootWithTimeout = async () => {
setIsScheduled(true);
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
handleReboot(delay);
const id = setTimeout(() => {
setIsScheduled(false);
onOpenChange(false);
setInputValue(DEFAULT_REBOOT_DELAY.toString());
}, delay * 1000);
setTimeoutId(id);
};
const handleCancel = () => {
clearTimeout(timeoutId);
setIsScheduled(false);
handleReboot(-1);
};
const handleInstantReboot = async () => {
handleReboot(0);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -32,24 +93,66 @@ export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => {
<DialogTitle>{t("reboot.title")}</DialogTitle>
<DialogDescription>{t("reboot.description")}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-4">
<Input
type="number"
className="dark:text-slate-900"
value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
/>
<Button
className="w-24"
name="now"
onClick={() => {
connection?.reboot(2).then(() => onOpenChange(false));
}}
>
<RefreshCwIcon className="mr-2" size={16} />
{t("button.now")}
</Button>
</div>
<Separator />
{!isScheduled ? (
<>
<Checkbox
checked={isOTA}
onChange={(checked) => setIsOTA(checked)}
className="px-2"
>
{t("reboot.ota")}
</Checkbox>
<div className="flex gap-2 px-2 items-center relative">
<Input
type="number"
min={1}
max={86400}
value={inputValue}
onChange={handleSetTime}
placeholder={t("reboot.enterDelay")}
suffix={t("unit.second.plural")}
/>
<Button
onClick={() => handleRebootWithTimeout()}
data-testid="scheduleRebootBtn"
className="w-9/12"
>
<ClockIcon className="mr-2" size={18} />
{t("reboot.schedule")}
</Button>
</div>
<div className="px-2">
<Button
variant="destructive"
name="rebootNow"
onClick={() => handleInstantReboot()}
className=" w-full"
>
<RefreshCwIcon className="mr-2" size={16} />
{t("reboot.now")}
</Button>
</div>
</>
) : (
<div className="px-2">
<div className="pb-6 pt-2 text-center">
<Label className=" text-gray-700 dark:text-gray-300 ">
{t("reboot.scheduled")}
</Label>
</div>
<Button
variant="destructive"
name="cancelReboot"
onClick={() => handleCancel()}
className=" w-full"
data-testid="cancelRebootBtn"
>
<OctagonXIcon className="mr-2" size={16} />
{t("reboot.cancel")}
</Button>
</div>
)}
</DialogContent>
</Dialog>
);

View File

@@ -1,117 +0,0 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export interface RebootOTADialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootOTADialog = ({
open,
onOpenChange,
}: RebootOTADialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false);
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString());
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) {
e.preventDefault();
return;
}
const val = e.target.value;
setInputValue(val);
const parsed = Number(val);
if (!Number.isNaN(parsed) && parsed > 0) {
setTime(parsed);
}
};
const handleRebootWithTimeout = async () => {
if (!connection) {
return;
}
setIsScheduled(true);
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, delay * 1000);
}).finally(() => {
setIsScheduled(false);
onOpenChange(false);
setInputValue(DEFAULT_REBOOT_DELAY.toString());
});
connection.rebootOta(0);
};
const handleInstantReboot = async () => {
if (!connection) {
return;
}
await connection.rebootOta(DEFAULT_REBOOT_DELAY);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("rebootOta.title")}</DialogTitle>
<DialogDescription>{t("rebootOta.description")}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-2 items-center relative">
<Input
type="number"
min={1}
max={86400}
className="dark:text-slate-900 appearance-none"
value={inputValue}
onChange={handleSetTime}
placeholder={t("rebootOta.enterDelay")}
/>
<Button
onClick={() => handleRebootWithTimeout()}
data-testid="scheduleRebootBtn"
className="w-9/12"
>
<ClockIcon className="mr-2" size={18} />
{isScheduled ? t("rebootOta.scheduled") : t("rebootOta.title")}
</Button>
</div>
<Button
variant="destructive"
name="rebootNow"
onClick={() => handleInstantReboot()}
>
<RefreshCwIcon className="mr-2" size={16} />
{t("button.rebootOtaNow")}
</Button>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,11 +1,16 @@
import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
import { DeviceContext, useDeviceStore, useMessageStore } from "@core/stores";
import { render } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
vi.mock("@core/stores/messageStore");
vi.mock("@core/stores", async () => {
const actual = (await vi.importActual("@core/stores")) as typeof import("@core/stores");
return {
...actual,
useMessageStore: vi.fn(),
};
});
vi.mock("./useRefreshKeysDialog");
const mockUseMessageStore = vi.mocked(useMessageStore);

View File

@@ -6,8 +6,7 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useDevice, useMessageStore } from "@core/stores";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";

View File

@@ -1,5 +1,4 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useDevice, useMessageStore } from "@core/stores";
import { useCallback } from "react";
export function useRefreshKeysDialog() {

View File

@@ -9,9 +9,8 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Label } from "@components/UI/Label.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore, useDevice } from "@core/stores";
import { useTranslation } from "react-i18next";
import { useAppStore } from "../../core/stores/appStore.ts";
export interface RemoveNodeDialogProps {
open: boolean;

View File

@@ -8,7 +8,7 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { ClockIcon, PowerIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,4 +1,4 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";

View File

@@ -10,7 +10,7 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { eventBus } from "@core/utils/eventBus.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,4 +1,4 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { eventBus } from "@core/utils/eventBus.ts";
import { useCallback } from "react";

View File

@@ -7,7 +7,7 @@ import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useAppStore } from "@core/stores";
import { dotPaths } from "@core/utils/dotPath.ts";
import { useEffect } from "react";
import {

View File

@@ -1,5 +1,5 @@
import { useBackupReminder } from "@core/hooks/useKeyBackupReminder.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { useTranslation } from "react-i18next";
export const KeyBackupReminder = () => {

View File

@@ -3,7 +3,7 @@ import { create } from "@bufbuild/protobuf";
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -9,7 +9,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import {
convertIntToIpAddress,

View File

@@ -12,7 +12,7 @@ import {
type FlagName,
usePositionFlags,
} from "@core/hooks/usePositionFlags.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -12,8 +12,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore, useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import { Protobuf } from "@meshtastic/core";
@@ -197,14 +196,12 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
description: t("security.primaryAdminKey.description"),
bits,
devicePSKBitCount: 32,
hide: true,
actionButtons: [],
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
showCopyButton: true,
showPasswordToggle: true,
},
},
{
@@ -215,14 +212,12 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
description: t("security.secondaryAdminKey.description"),
bits,
devicePSKBitCount: 32,
hide: true,
actionButtons: [],
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
showCopyButton: true,
showPasswordToggle: true,
},
},
{
@@ -233,14 +228,12 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
description: t("security.tertiaryAdminKey.description"),
bits,
devicePSKBitCount: 32,
hide: true,
actionButtons: [],
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
showCopyButton: true,
showPasswordToggle: true,
},
},
{

View File

@@ -1,8 +1,6 @@
import { Mono } from "@components/generic/Mono.tsx";
import { Button } from "@components/UI/Button.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useAppStore, useDeviceStore, useMessageStore } from "@core/stores";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";

View File

@@ -4,14 +4,12 @@ import { TransportHTTP } from "@meshtastic/transport-http";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@core/stores/appStore.ts", () => ({
vi.mock("@core/stores", () => ({
useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
}));
vi.mock("@core/stores/deviceStore.ts", () => ({
useDeviceStore: vi.fn(() => ({
addDevice: vi.fn(() => ({ addConnection: vi.fn() })),
})),
useMessageStore: vi.fn()
}));
vi.mock("@core/utils/randId.ts", () => ({

View File

@@ -4,9 +4,7 @@ import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useAppStore, useDeviceStore, useMessageStore } from "@core/stores";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";

View File

@@ -1,8 +1,6 @@
import { Mono } from "@components/generic/Mono.tsx";
import { Button } from "@components/UI/Button.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useAppStore, useDeviceStore, useMessageStore } from "@core/stores";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";

View File

@@ -1,6 +1,6 @@
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useMessageStore } from "@core/stores";
import type { Types } from "@meshtastic/core";
import { SendIcon } from "lucide-react";
import { startTransition, useState } from "react";

View File

@@ -6,11 +6,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import {
MessageState,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { MessageState, useDevice, useMessageStore } from "@core/stores";
import type { Message } from "@core/stores/messageStore/types.ts";
import { cn } from "@core/utils/cn.ts";
import { type Protobuf, Types } from "@meshtastic/core";

View File

@@ -1,11 +1,11 @@
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { mockDeviceStore } from "@core/stores/deviceStore.mock.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { mockDeviceStore } from "@core/stores/deviceStore/deviceStore.mock.ts";
import { useDevice } from "@core/stores";
import { Protobuf } from "@meshtastic/core";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@core/stores/deviceStore");
vi.mock("@core/stores");
describe("TraceRoute", () => {
const fromUser = {

View File

@@ -1,4 +1,4 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";

View File

@@ -2,10 +2,7 @@ import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import type { Page } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { type Page, useAppStore, useDevice, useSidebar } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { useLocation, useNavigate } from "@tanstack/react-router";
import {

View File

@@ -1,56 +1,75 @@
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@components/UI/Label.tsx", () => ({
Label: ({
children,
className,
htmlFor,
id,
}: {
children: React.ReactNode;
className: string;
htmlFor: string;
id: string;
}) => (
<label
data-testid="label-component"
className={className}
htmlFor={htmlFor}
id={id}
>
{children}
</label>
),
}));
describe("Checkbox", () => {
beforeEach(cleanup);
it("renders unchecked by default", () => {
it("renders unchecked by default (uncontrolled)", () => {
render(<Checkbox />);
const checkbox = screen.getByRole("checkbox");
const presentation = screen.getByRole("presentation");
expect(checkbox).not.toBeChecked();
expect(screen.queryByText("Check")).not.toBeInTheDocument();
// unchecked -> no filled bg class
expect(presentation).not.toHaveClass("bg-slate-500");
});
it("renders checked when checked prop is true", () => {
render(<Checkbox checked />);
it("respects defaultChecked in uncontrolled mode", () => {
render(<Checkbox defaultChecked />);
expect(screen.getByRole("checkbox")).toBeChecked();
expect(screen.getByRole("presentation")).toBeInTheDocument();
});
it("calls onChange when clicked", () => {
it("renders checked when controlled with checked=true", () => {
render(<Checkbox checked />);
const checkbox = screen.getByRole("checkbox");
const presentation = screen.getByRole("presentation");
expect(checkbox).toBeChecked();
expect(presentation).toHaveClass("bg-slate-500");
});
it("calls onChange when clicked (uncontrolled) and toggles DOM state", () => {
const onChange = vi.fn();
render(<Checkbox onChange={onChange} />);
fireEvent.click(screen.getByRole("presentation"));
expect(onChange).toHaveBeenCalledWith(true);
const checkbox = screen.getByRole("checkbox");
const presentation = screen.getByRole("presentation");
fireEvent.click(screen.getByRole("presentation"));
expect(onChange).toHaveBeenCalledWith(false);
fireEvent.click(presentation);
expect(onChange).toHaveBeenLastCalledWith(true);
expect(checkbox).toBeChecked();
fireEvent.click(presentation);
expect(onChange).toHaveBeenLastCalledWith(false);
expect(checkbox).not.toBeChecked();
});
it("controlled: calls onChange but does not toggle without prop update", () => {
const onChange = vi.fn();
render(<Checkbox checked={false} onChange={onChange} />);
const checkbox = screen.getByRole("checkbox");
const presentation = screen.getByRole("presentation");
fireEvent.click(presentation);
expect(onChange).toHaveBeenLastCalledWith(true);
// still unchecked because parent didn't update prop
expect(checkbox).not.toBeChecked();
});
it("controlled: reflects external prop changes after onChange", () => {
const onChange = vi.fn();
const { rerender } = render(<Checkbox checked={false} onChange={onChange} />);
const checkbox = screen.getByRole("checkbox");
const presentation = screen.getByRole("presentation");
fireEvent.click(presentation);
expect(onChange).toHaveBeenLastCalledWith(true);
// parent updates `checked` based on onChange
rerender(<Checkbox checked={true} onChange={onChange} />);
expect(checkbox).toBeChecked();
expect(presentation).toHaveClass("bg-slate-500");
});
it("uses provided id", () => {
@@ -58,23 +77,16 @@ describe("Checkbox", () => {
expect(screen.getByRole("checkbox").id).toBe("custom-id");
});
it("renders children in Label component", () => {
it("renders children inside the label", () => {
render(<Checkbox>Test Label</Checkbox>);
expect(screen.getByTestId("label-component")).toHaveTextContent(
"Test Label",
);
expect(screen.getByTestId("label-component")).toHaveTextContent("Test Label");
});
it("applies custom className", () => {
it("applies custom className to wrapper label", () => {
const { container } = render(<Checkbox className="custom-class" />);
expect(container.firstChild).toHaveClass("custom-class");
});
it("applies labelClassName to Label", () => {
render(<Checkbox labelClassName="label-class">Test</Checkbox>);
expect(screen.getByTestId("label-component")).toHaveClass("label-class");
});
it("disables checkbox when disabled prop is true", () => {
render(<Checkbox disabled />);
expect(screen.getByRole("checkbox")).toBeDisabled();
@@ -84,7 +96,6 @@ describe("Checkbox", () => {
it("does not call onChange when disabled", () => {
const onChange = vi.fn();
render(<Checkbox onChange={onChange} disabled />);
fireEvent.click(screen.getByRole("presentation"));
expect(onChange).not.toHaveBeenCalled();
});
@@ -99,24 +110,19 @@ describe("Checkbox", () => {
expect(screen.getByRole("checkbox")).toHaveAttribute("name", "test-name");
});
it("passes through additional props", () => {
it("passes through additional props to the input", () => {
render(<Checkbox data-testid="extra-prop" />);
expect(screen.getByRole("checkbox")).toHaveAttribute(
"data-testid",
"extra-prop",
);
expect(screen.getByRole("checkbox")).toHaveAttribute("data-testid", "extra-prop");
});
it("toggles checked state correctly", () => {
it("uncontrolled: toggles checked state when clicking the visual box", () => {
render(<Checkbox />);
const checkbox = screen.getByRole("checkbox");
const presentation = screen.getByRole("presentation");
expect(checkbox).not.toBeChecked();
fireEvent.click(presentation);
expect(checkbox).toBeChecked();
fireEvent.click(presentation);
expect(checkbox).not.toBeChecked();
});

View File

@@ -1,9 +1,10 @@
import { cn } from "@core/utils/cn.ts";
import { Check } from "lucide-react";
import { useId } from "react";
import { useId, useState } from "react";
interface CheckboxProps {
checked?: boolean;
defaultChecked?: boolean;
onChange?: (checked: boolean) => void;
className?: string;
labelClassName?: string;
@@ -15,7 +16,8 @@ interface CheckboxProps {
}
export function Checkbox({
checked = false,
checked,
defaultChecked = false,
onChange,
className,
id: propId,
@@ -28,10 +30,21 @@ export function Checkbox({
const generatedId = useId();
const id = propId || generatedId;
const handleToggle = (): void => {
if (!disabled) {
onChange?.(!checked);
const isControlled = checked !== undefined;
const [internal, setInternal] = useState<boolean>(defaultChecked);
const value = checked ?? internal;
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) {
return;
}
const next = e.target.checked;
if (!isControlled) {
setInternal(next);
}
onChange?.(next);
};
return (
@@ -41,11 +54,12 @@ export function Checkbox({
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
className,
)}
data-testid="label-component"
>
<input
type="checkbox"
id={id}
checked={checked}
checked={value}
onChange={handleToggle}
disabled={disabled}
required={required}
@@ -57,10 +71,12 @@ export function Checkbox({
className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 border-gray-500 transition-colors",
"peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2",
{ "border-slate-500 bg-slate-500": checked },
{ "border-slate-500 bg-slate-500": value },
{ "opacity-50": disabled },
)}
role="presentation"
>
{checked && (
{value && (
<div className="animate-fade-in">
<Check className="h-4 w-4 text-white" />
</div>

View File

@@ -1,5 +1,5 @@
import { Button } from "@components/UI/Button.tsx";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { useSidebar } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import type { LucideIcon } from "lucide-react";
import type React from "react";

View File

@@ -1,5 +1,5 @@
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { useSidebar } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import type React from "react";

View File

@@ -170,6 +170,7 @@ export const FilterMulti = <K extends EnumArrayKeys<FilterState>>({
key={val}
checked={selected.includes(val)}
onChange={(checked) => toggleValue(val, checked)}
className="flex items-center gap-2"
>
<span className="dark:text-slate-200">{getLabel(val)}</span>
</Checkbox>

View File

@@ -1,4 +1,4 @@
import { MessageState, MessageType } from "@core/stores/messageStore/index.ts";
import { MessageState, MessageType } from "@core/stores";
import type { Message } from "@core/stores/messageStore/types.ts";
import type { Types } from "@meshtastic/core";

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