diff --git a/.changeset/desktop-device-display-name.md b/.changeset/desktop-device-display-name.md new file mode 100644 index 0000000000..239281d6b0 --- /dev/null +++ b/.changeset/desktop-device-display-name.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Settings device name now clearly shows your device type — e.g. "Sable Desktop on Windows", "Sable Mobile on Android", or "Sable Web on Firefox for macOS" diff --git a/.github/actions/prepare-tofu/action.yml b/.github/actions/prepare-tofu/action.yml index 97c2944c3f..8e04613ab5 100644 --- a/.github/actions/prepare-tofu/action.yml +++ b/.github/actions/prepare-tofu/action.yml @@ -2,10 +2,10 @@ name: Prepare OpenTofu Deployment description: Prepares OpenTofu in infra/web. inputs: - is_release_tag: - description: Whether the build is for a release tag. Passed through to VITE_IS_RELEASE_TAG. + build-flavor: + description: Build flavor (stable or dev). Controls app build behavior. required: false - default: 'false' + default: stable runs: using: composite @@ -15,7 +15,7 @@ runs: with: build: 'true' env: - VITE_IS_RELEASE_TAG: ${{ inputs.is_release_tag }} + SABLE_BUILD_FLAVOR: ${{ inputs.build-flavor }} VITE_SENTRY_DSN: ${{ env.VITE_SENTRY_DSN }} VITE_SENTRY_ENVIRONMENT: ${{ env.VITE_SENTRY_ENVIRONMENT }} VITE_APP_VERSION: ${{ env.VITE_APP_VERSION }} diff --git a/.github/actions/resolve-release-meta/action.yml b/.github/actions/resolve-release-meta/action.yml new file mode 100644 index 0000000000..59b0fdd5c7 --- /dev/null +++ b/.github/actions/resolve-release-meta/action.yml @@ -0,0 +1,41 @@ +name: Resolve release meta +description: Detect whether the current build is a nightly/dev or stable release and output tag, version, ref, and build flavor. + +inputs: + input-tag: + description: Explicit tag for workflow_dispatch triggers + required: false + default: '' + +outputs: + tag: + description: Release tag name (e.g. "nightly" or "v1.2.3") + value: ${{ steps.meta.outputs.tag }} + version: + description: SemVer version string + value: ${{ steps.meta.outputs.version }} + ref: + description: Git ref (sha for nightly, tag ref for stable) + value: ${{ steps.meta.outputs.ref }} + nightly: + description: '"true" if this is a nightly/dev build' + value: ${{ steps.meta.outputs.nightly }} + started_at: + description: ISO 8601 timestamp of when the build started + value: ${{ steps.meta.outputs.started_at }} + build_flavor: + description: '"dev" or "stable"' + value: ${{ steps.meta.outputs.build_flavor }} + +runs: + using: composite + steps: + - shell: bash + id: meta + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.input-tag }} + GIT_REF: ${{ github.ref }} + GIT_REF_NAME: ${{ github.ref_name }} + GIT_SHA: ${{ github.sha }} + run: node ${{ github.action_path }}/../../scripts/release-meta.mjs "$GITHUB_OUTPUT" diff --git a/.github/scripts/build-updater-manifest.mjs b/.github/scripts/build-updater-manifest.mjs index 78d176c7f1..11ca6c7362 100644 --- a/.github/scripts/build-updater-manifest.mjs +++ b/.github/scripts/build-updater-manifest.mjs @@ -6,6 +6,7 @@ import { execSync } from 'node:child_process'; import { readFileSync, readdirSync, writeFileSync, mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { resolveReleaseMeta } from './release-meta.mjs'; const TAG = process.env.TAG; const VERSION = process.env.VERSION; @@ -20,6 +21,14 @@ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(VERSION)) { } const version = VERSION; +const { isNightly } = resolveReleaseMeta({ + eventName: process.env.EVENT_NAME, + inputTag: process.env.INPUT_TAG, + gitRef: process.env.GIT_REF, + gitRefName: process.env.GIT_REF_NAME, + gitSha: process.env.GIT_SHA, +}); + const dir = mkdtempSync(join(tmpdir(), 'sable-sigs-')); execSync(`gh release download "${TAG}" --repo "${REPO}" --pattern '*.sig' --dir "${dir}"`, { stdio: 'inherit', @@ -55,7 +64,7 @@ if (Object.keys(platforms).length === 0) { } let notes = `Sable ${version}`; -if (TAG !== 'nightly') { +if (!isNightly) { try { notes = execSync(`gh release view "${TAG}" --repo "${REPO}" --json body -q .body`, { diff --git a/.github/scripts/nightly-version.mjs b/.github/scripts/nightly-version.mjs deleted file mode 100644 index 26a38c220d..0000000000 --- a/.github/scripts/nightly-version.mjs +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -/* oxlint-disable no-console */ - -import { execSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; - -const packageJson = JSON.parse(readFileSync('package.json', 'utf8')); -const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(packageJson.version); - -if (!match) { - console.error(`package.json version must be stable SemVer (got: ${packageJson.version})`); - process.exit(1); -} - -let timestamp; -try { - // Read the HEAD commit's committer timestamp in UTC epoch seconds - const commitTimestamp = execSync('git log -1 --format=%ct', { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - if (/^\d+$/.test(commitTimestamp)) { - timestamp = new Date(Number(commitTimestamp) * 1000); - } -} catch { - // Fallback to current system time if git is unavailable -} - -timestamp ||= new Date(); - -const year = String(timestamp.getUTCFullYear()).slice(-2); -const month = String(timestamp.getUTCMonth() + 1).padStart(2, '0'); -const day = String(timestamp.getUTCDate()).padStart(2, '0'); -const hours = String(timestamp.getUTCHours()).padStart(2, '0'); -const minutes = String(timestamp.getUTCMinutes()).padStart(2, '0'); - -const timeIdentifier = `${year}${month}${day}${hours}${minutes}`; - -const [, major, minor, patch] = match; -console.log(`${major}.${minor}.${Number(patch) + 1}-nightly.${timeIdentifier}`); diff --git a/.github/scripts/release-meta.mjs b/.github/scripts/release-meta.mjs new file mode 100644 index 0000000000..77c06accaf --- /dev/null +++ b/.github/scripts/release-meta.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/* oxlint-disable no-console */ + +import { execSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +export function computeNightlyVersion(baseVersion, timestamp = new Date()) { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(baseVersion); + if (!match) { + throw new Error(`Version must be stable SemVer (got: ${baseVersion})`); + } + + const y = String(timestamp.getUTCFullYear()).slice(-2); + const m = String(timestamp.getUTCMonth() + 1).padStart(2, '0'); + const d = String(timestamp.getUTCDate()).padStart(2, '0'); + const h = String(timestamp.getUTCHours()).padStart(2, '0'); + const min = String(timestamp.getUTCMinutes()).padStart(2, '0'); + + const [, major, minor, patch] = match; + return `${major}.${minor}.${Number(patch) + 1}-nightly.${y}${m}${d}${h}${min}`; +} + +export function nightlyVersion() { + const { version } = JSON.parse(readFileSync('package.json', 'utf8')); + + let timestamp; + try { + const commitTimestamp = execSync('git log -1 --format=%ct', { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + if (/^\d+$/.test(commitTimestamp)) { + timestamp = new Date(Number(commitTimestamp) * 1000); + } + } catch {} + + return computeNightlyVersion(version, timestamp); +} + +export async function writeOutputs(outputPath, entries) { + const { appendFileSync } = await import('node:fs'); + appendFileSync(outputPath, entries.map((e) => `${e.key}=${e.value}`).join('\n') + '\n'); +} + +export function resolveReleaseMeta({ + eventName = '', + inputTag = '', + gitRef = '', + gitRefName = '', + gitSha = '', +} = {}) { + const startedAt = new Date().toISOString(); + let tag, ref, isNightly, version; + + if (eventName === 'workflow_dispatch' && inputTag) { + tag = inputTag; + ref = inputTag; + isNightly = false; + version = tag.replace(/^v/, ''); + } else if (gitRef === 'refs/heads/dev') { + tag = 'nightly'; + ref = gitSha; + isNightly = true; + version = nightlyVersion(); + } else if (/^v\d+\./.test(gitRefName)) { + tag = gitRefName; + ref = gitRef; + isNightly = false; + version = gitRefName.replace(/^v/, ''); + } else { + tag = ''; + ref = gitRef; + isNightly = false; + version = ''; + } + + return { + tag, + version, + ref, + isNightly, + startedAt, + buildFlavor: isNightly ? 'dev' : 'stable', + }; +} + +const isMain = + process.argv[1] && import.meta.url === `file:///${process.argv[1].replace(/\\/g, '/')}`; +if (isMain) { + try { + const meta = resolveReleaseMeta({ + eventName: process.env.EVENT_NAME ?? '', + inputTag: process.env.INPUT_TAG ?? '', + gitRef: process.env.GIT_REF ?? '', + gitRefName: process.env.GIT_REF_NAME ?? '', + gitSha: process.env.GIT_SHA ?? '', + }); + + const outputPath = process.argv[2]; + const entries = [ + { key: 'tag', value: meta.tag }, + { key: 'version', value: meta.version }, + { key: 'ref', value: meta.ref }, + { key: 'nightly', value: String(meta.isNightly) }, + { key: 'started_at', value: meta.startedAt }, + { key: 'build_flavor', value: meta.buildFlavor }, + ]; + + if (outputPath) { + await writeOutputs(outputPath, entries); + } else { + entries.forEach(({ key, value }) => console.log(`${key}=${value}`)); + } + } catch (err) { + console.error(err.message); + process.exit(1); + } +} diff --git a/.github/workflows/cloudflare-web-deploy.yml b/.github/workflows/cloudflare-web-deploy.yml deleted file mode 100644 index e27905e811..0000000000 --- a/.github/workflows/cloudflare-web-deploy.yml +++ /dev/null @@ -1,134 +0,0 @@ -name: Cloudflare Infra - -on: - pull_request: - paths: - - 'infra/web/**' - - '.github/workflows/cloudflare-web-deploy.yml' - - '.github/actions/prepare-tofu/**' - - '.github/actions/setup/**' - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - git_tag: - description: 'Git tag to deploy (e.g. v1.2.3). Leave empty to deploy current HEAD.' - required: false - type: string - -env: - CLOUDFLARE_API_TOKEN: ${{ secrets.TF_CLOUDFLARE_API_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TF_VAR_workers_message: ${{ github.event_name == 'pull_request' && github.event.pull_request.title || github.ref_name }} - TF_VAR_account_id: ${{ secrets.TF_VAR_ACCOUNT_ID }} - TF_VAR_zone_id: ${{ secrets.TF_VAR_ZONE_ID }} - TF_HTTP_ADDRESS: ${{ secrets.TF_HTTP_ADDRESS }} - TF_HTTP_LOCK_ADDRESS: ${{ secrets.TF_HTTP_LOCK_ADDRESS }} - TF_HTTP_UNLOCK_ADDRESS: ${{ secrets.TF_HTTP_UNLOCK_ADDRESS }} - TF_HTTP_USERNAME: ${{ secrets.TF_HTTP_USERNAME }} - TF_HTTP_PASSWORD: ${{ secrets.TF_HTTP_PASSWORD }} - TF_HTTP_LOCK_METHOD: 'POST' - TF_HTTP_UNLOCK_METHOD: 'DELETE' - TF_HTTP_RETRY_WAIT_MIN: '5' - -concurrency: - group: cloudflare-infra - cancel-in-progress: false - -jobs: - plan: - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - defaults: - run: - working-directory: infra/web - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Resolve release version - id: version - shell: bash - env: - GIT_TAG: ${{ inputs.git_tag }} - REF_NAME: ${{ github.ref_name }} - EVENT_NAME: ${{ github.event_name }} - run: | - if [[ "$EVENT_NAME" == "workflow_dispatch" && -n "$GIT_TAG" ]]; then - TAG="$GIT_TAG" - else - TAG="$REF_NAME" - fi - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" - - - name: Prepare OpenTofu deployment - uses: ./.github/actions/prepare-tofu - with: - is_release_tag: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.git_tag != '') }} - env: - VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} - VITE_SENTRY_ENVIRONMENT: production - VITE_APP_VERSION: ${{ steps.version.outputs.version }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ secrets.SENTRY_ORG }} - SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - - - name: Comment PR plan - uses: dflook/tofu-plan@cdb4f4d604c9fa695e457744dd1f802853397ce7 # v3.0.0 - with: - path: infra/web - label: production - - apply: - if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - permissions: - contents: read - defaults: - run: - working-directory: infra/web - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_tag || '' }} - - - name: Resolve release version - id: version - shell: bash - env: - GIT_TAG: ${{ inputs.git_tag }} - REF_NAME: ${{ github.ref_name }} - EVENT_NAME: ${{ github.event_name }} - run: | - if [[ "$EVENT_NAME" == "workflow_dispatch" && -n "$GIT_TAG" ]]; then - TAG="$GIT_TAG" - else - TAG="$REF_NAME" - fi - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" - - - name: Prepare OpenTofu deployment - uses: ./.github/actions/prepare-tofu - with: - is_release_tag: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.git_tag != '') }} - env: - VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} - VITE_SENTRY_ENVIRONMENT: production - VITE_APP_VERSION: ${{ steps.version.outputs.version }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ secrets.SENTRY_ORG }} - SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - - - name: Plan infrastructure - run: mise run opentofu -- plan -input=false -no-color - - - name: Apply infrastructure - run: mise run opentofu -- apply -input=false -auto-approve diff --git a/.github/workflows/cloudflare-web-dev.yml b/.github/workflows/cloudflare-web-dev.yml deleted file mode 100644 index 81ce93aa60..0000000000 --- a/.github/workflows/cloudflare-web-dev.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Cloudflare Infra (Dev) - -on: - push: - branches: - - dev - workflow_dispatch: - -env: - CLOUDFLARE_API_TOKEN: ${{ secrets.TF_CLOUDFLARE_API_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TF_VAR_workers_message: ${{ github.event_name == 'pull_request' && github.event.pull_request.title || github.ref_name }} - TF_VAR_account_id: ${{ secrets.TF_VAR_ACCOUNT_ID }} - TF_VAR_zone_id: ${{ secrets.TF_VAR_ZONE_ID }} - TF_VAR_custom_domain: 'dev.sable.moe' - TF_VAR_worker_name: 'sable-dev' - TF_HTTP_ADDRESS: ${{ secrets.TF_HTTP_ADDRESS_DEV }} - TF_HTTP_LOCK_ADDRESS: ${{ secrets.TF_HTTP_LOCK_ADDRESS_DEV }} - TF_HTTP_UNLOCK_ADDRESS: ${{ secrets.TF_HTTP_UNLOCK_ADDRESS_DEV }} - TF_HTTP_USERNAME: ${{ secrets.TF_HTTP_USERNAME }} - TF_HTTP_PASSWORD: ${{ secrets.TF_HTTP_PASSWORD }} - TF_HTTP_LOCK_METHOD: 'POST' - TF_HTTP_UNLOCK_METHOD: 'DELETE' - TF_HTTP_RETRY_WAIT_MIN: '5' - -concurrency: - group: cloudflare-infra-dev - cancel-in-progress: false - -jobs: - apply: - runs-on: ubuntu-latest - permissions: - contents: read - defaults: - run: - working-directory: infra/web - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Compute nightly version - id: version - shell: bash - working-directory: ${{ github.workspace }} - run: | - VERSION="$(node .github/scripts/nightly-version.mjs)" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Prepare OpenTofu deployment - uses: ./.github/actions/prepare-tofu - with: - is_release_tag: 'true' - env: - VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} - VITE_SENTRY_ENVIRONMENT: development - VITE_APP_VERSION: ${{ steps.version.outputs.version }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ secrets.SENTRY_ORG }} - SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - - - name: Plan infrastructure - run: mise run opentofu -- plan -input=false -no-color - - - name: Apply infrastructure - run: mise run opentofu -- apply -input=false -auto-approve diff --git a/.github/workflows/cloudflare-web.yml b/.github/workflows/cloudflare-web.yml new file mode 100644 index 0000000000..f4719d6fb0 --- /dev/null +++ b/.github/workflows/cloudflare-web.yml @@ -0,0 +1,148 @@ +name: Cloudflare Infra + +on: + push: + branches: [dev] + tags: ['v*'] + pull_request: + paths: + - 'infra/web/**' + - '.github/workflows/cloudflare-web.yml' + - '.github/actions/prepare-tofu/**' + - '.github/actions/setup/**' + workflow_dispatch: + inputs: + git_tag: + description: 'Git tag to deploy (e.g. v1.2.3). Empty = deploy current HEAD as dev build.' + required: false + type: string + +env: + CLOUDFLARE_API_TOKEN: ${{ secrets.TF_CLOUDFLARE_API_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TF_VAR_account_id: ${{ secrets.TF_VAR_ACCOUNT_ID }} + TF_VAR_zone_id: ${{ secrets.TF_VAR_ZONE_ID }} + TF_HTTP_USERNAME: ${{ secrets.TF_HTTP_USERNAME }} + TF_HTTP_PASSWORD: ${{ secrets.TF_HTTP_PASSWORD }} + TF_VAR_workers_message: ${{ github.event_name == 'pull_request' && github.event.pull_request.title || github.ref_name }} + TF_HTTP_LOCK_METHOD: 'POST' + TF_HTTP_UNLOCK_METHOD: 'DELETE' + TF_HTTP_RETRY_WAIT_MIN: '5' + +jobs: + detect: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + environment: ${{ steps.env.outputs.environment }} + sentry_environment: ${{ steps.env.outputs.sentry_environment }} + build_flavor: ${{ steps.version.outputs.build_flavor }} + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_tag || '' }} + + - uses: ./.github/actions/resolve-release-meta + id: version + with: + input-tag: ${{ inputs.git_tag }} + + - name: Detect environment + id: env + env: + REF_NAME: ${{ github.ref_name }} + EVENT_NAME: ${{ github.event_name }} + INPUT_GIT_TAG: ${{ inputs.git_tag }} + run: | + if [[ "${REF_NAME}" == "dev" && "${EVENT_NAME}" != "workflow_dispatch" ]] || [[ "${EVENT_NAME}" == "workflow_dispatch" && "${INPUT_GIT_TAG}" == "" ]]; then + echo "environment=development" >> "$GITHUB_OUTPUT" + echo "sentry_environment=development" >> "$GITHUB_OUTPUT" + else + echo "environment=production" >> "$GITHUB_OUTPUT" + echo "sentry_environment=production" >> "$GITHUB_OUTPUT" + fi + + plan: + needs: detect + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + environment: + name: ${{ needs.detect.outputs.environment }} + env: + TF_HTTP_ADDRESS: ${{ secrets.TF_HTTP_ADDRESS }} + TF_HTTP_LOCK_ADDRESS: ${{ secrets.TF_HTTP_LOCK_ADDRESS }} + TF_HTTP_UNLOCK_ADDRESS: ${{ secrets.TF_HTTP_UNLOCK_ADDRESS }} + TF_VAR_custom_domain: ${{ vars.TF_VAR_CUSTOM_DOMAIN || 'app.sable.moe' }} + TF_VAR_worker_name: ${{ vars.TF_VAR_WORKER_NAME || 'sable' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_tag || '' }} + + - uses: ./.github/actions/prepare-tofu + with: + build-flavor: ${{ needs.detect.outputs.build_flavor }} + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: ${{ needs.detect.outputs.sentry_environment }} + VITE_APP_VERSION: ${{ needs.detect.outputs.version }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + + - name: Plan infrastructure + working-directory: infra/web + run: mise run opentofu -- plan -input=false -no-color + + - name: Comment PR plan + if: github.event_name == 'pull_request' + uses: dflook/tofu-plan@cdb4f4d604c9fa695e457744dd1f802853397ce7 # v3.0.0 + with: + path: infra/web + label: ${{ needs.detect.outputs.environment }} + + apply: + needs: [detect, plan] + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + environment: + name: ${{ needs.detect.outputs.environment }} + url: https://${{ vars.TF_VAR_CUSTOM_DOMAIN || 'app.sable.moe' }} + env: + TF_HTTP_ADDRESS: ${{ secrets.TF_HTTP_ADDRESS }} + TF_HTTP_LOCK_ADDRESS: ${{ secrets.TF_HTTP_LOCK_ADDRESS }} + TF_HTTP_UNLOCK_ADDRESS: ${{ secrets.TF_HTTP_UNLOCK_ADDRESS }} + TF_VAR_custom_domain: ${{ vars.TF_VAR_CUSTOM_DOMAIN || 'app.sable.moe' }} + TF_VAR_worker_name: ${{ vars.TF_VAR_WORKER_NAME || 'sable' }} + concurrency: + group: cloudflare-infra-${{ needs.detect.outputs.environment }} + cancel-in-progress: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_tag || '' }} + + - uses: ./.github/actions/prepare-tofu + with: + build-flavor: ${{ needs.detect.outputs.build_flavor }} + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: ${{ needs.detect.outputs.sentry_environment }} + VITE_APP_VERSION: ${{ needs.detect.outputs.version }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + + - name: Apply infrastructure + working-directory: infra/web + run: mise run opentofu -- apply -input=false -auto-approve diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index e2a1bfbde3..0550330041 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -45,28 +45,10 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Resolve release tag value - id: release_tag - shell: bash - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" && -n "${INPUTS_GIT_TAG}" ]]; then - TAG="${INPUTS_GIT_TAG}" - else - TAG="${GITHUB_REF#refs/tags/}" - fi - if [[ "${TAG}" == v* ]]; then - echo "value=${TAG#v}" >> "$GITHUB_OUTPUT" - echo "is_release=true" >> "$GITHUB_OUTPUT" - elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then - NIGHTLY_VERSION="$(node .github/scripts/nightly-version.mjs)" - echo "value=${NIGHTLY_VERSION}" >> "$GITHUB_OUTPUT" - echo "is_release=true" >> "$GITHUB_OUTPUT" - else - echo "value=" >> "$GITHUB_OUTPUT" - echo "is_release=false" >> "$GITHUB_OUTPUT" - fi - env: - INPUTS_GIT_TAG: ${{ inputs.git_tag }} + - uses: ./.github/actions/resolve-release-meta + id: release_meta + with: + input-tag: ${{ inputs.git_tag }} - name: Extract metadata id: meta @@ -77,14 +59,14 @@ jobs: latest=false tags: | # dev branch or manual dispatch without a tag: short commit SHA + dev - type=sha,prefix=,format=short,enable=${{ github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} - type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + type=sha,prefix=,format=short,enable=${{ steps.release_meta.outputs.version != '' && steps.release_meta.outputs.nightly == 'true' }} + type=raw,value=dev,enable=${{ steps.release_meta.outputs.version != '' && steps.release_meta.outputs.nightly == 'true' }} # git tags (push or manual dispatch with a tag): semver breakdown + latest - type=raw,value=latest,enable=${{ steps.release_tag.outputs.is_release == 'true' }} - type=semver,pattern={{version}},value=${{ steps.release_tag.outputs.value }},enable=${{ steps.release_tag.outputs.is_release == 'true' }} - type=semver,pattern={{major}}.{{minor}},value=${{ steps.release_tag.outputs.value }},enable=${{ steps.release_tag.outputs.is_release == 'true' }} - type=semver,pattern={{major}},value=${{ steps.release_tag.outputs.value }},enable=${{ steps.release_tag.outputs.is_release == 'true' && !startsWith(steps.release_tag.outputs.value, 'v0.') }} + type=raw,value=latest,enable=${{ steps.release_meta.outputs.version != '' }} + type=semver,pattern={{version}},value=${{ steps.release_meta.outputs.version }},enable=${{ steps.release_meta.outputs.version != '' }} + type=semver,pattern={{major}}.{{minor}},value=${{ steps.release_meta.outputs.version }},enable=${{ steps.release_meta.outputs.version != '' }} + type=semver,pattern={{major}},value=${{ steps.release_meta.outputs.version }},enable=${{ steps.release_meta.outputs.version != '' && !startsWith(steps.release_meta.outputs.version, 'v0.') }} - name: Compute short SHA id: vars @@ -96,8 +78,8 @@ jobs: - name: Build site env: VITE_BUILD_HASH: ${{ steps.vars.outputs.short_sha }} - VITE_APP_VERSION: ${{ steps.release_tag.outputs.value }} - VITE_IS_RELEASE_TAG: ${{ steps.release_tag.outputs.is_release }} + VITE_APP_VERSION: ${{ steps.release_meta.outputs.version }} + SABLE_BUILD_FLAVOR: ${{ steps.release_meta.outputs.build_flavor }} run: | NODE_OPTIONS=--max_old_space_size=4096 pnpm run build diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 1137898322..045aa74d58 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -39,36 +39,10 @@ jobs: with: persist-credentials: false - - name: Resolve target + - uses: ./.github/actions/resolve-release-meta id: meta - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - INPUT_TAG: ${{ inputs.tag }} - GIT_REF: ${{ github.ref }} - GIT_REF_NAME: ${{ github.ref_name }} - GIT_SHA: ${{ github.sha }} - RUN_NUMBER: ${{ github.run_number }} - RUN_ATTEMPT: ${{ github.run_attempt }} - run: | - STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - TAG="$INPUT_TAG"; REF="$INPUT_TAG"; NIGHTLY=false - VERSION="${TAG#v}" - elif [ "$GIT_REF" = "refs/heads/dev" ]; then - TAG="nightly"; REF="$GIT_SHA"; NIGHTLY=true - VERSION="$(node .github/scripts/nightly-version.mjs)" - else - TAG="$GIT_REF_NAME"; REF="$GIT_REF"; NIGHTLY=false - VERSION="${TAG#v}" - fi - { - echo "tag=$TAG" - echo "version=$VERSION" - echo "ref=$REF" - echo "nightly=$NIGHTLY" - echo "started_at=$STARTED_AT" - } >> "$GITHUB_OUTPUT" + with: + input-tag: ${{ inputs.tag }} - name: Wait for release to exist if: ${{ steps.meta.outputs.nightly != 'true' }} @@ -139,7 +113,7 @@ jobs: TAG: ${{ needs.setup-release.outputs.tag }} VERSION: ${{ needs.setup-release.outputs.version }} VITE_APP_VERSION: ${{ needs.setup-release.outputs.version }} - VITE_IS_RELEASE_TAG: ${{ needs.setup-release.outputs.nightly == 'true' && 'true' || '' }} + SABLE_BUILD_FLAVOR: ${{ needs.setup-release.outputs.nightly == 'true' && 'dev' || 'stable' }} VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} VITE_SENTRY_ENVIRONMENT: ${{ needs.setup-release.outputs.nightly == 'true' && 'preview' || 'production' }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -192,7 +166,7 @@ jobs: ARGS="$ARGS --bundles nsis" fi if [ "$IS_NIGHTLY" = "true" ]; then - ARGS="$ARGS --config src-tauri/tauri.nightly.conf.json" + echo "SABLE_BUILD_FLAVOR=dev" >> "$GITHUB_ENV" fi echo "TAURI_BUILD_ARGS=${ARGS# }" >> "$GITHUB_ENV" @@ -326,7 +300,7 @@ jobs: TAG: ${{ needs.setup-release.outputs.tag }} VERSION: ${{ needs.setup-release.outputs.version }} VITE_APP_VERSION: ${{ needs.setup-release.outputs.version }} - VITE_IS_RELEASE_TAG: ${{ needs.setup-release.outputs.nightly == 'true' && 'true' || '' }} + SABLE_BUILD_FLAVOR: ${{ needs.setup-release.outputs.nightly == 'true' && 'dev' || 'stable' }} VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} VITE_SENTRY_ENVIRONMENT: ${{ needs.setup-release.outputs.nightly == 'true' && 'preview' || 'production' }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -393,11 +367,10 @@ jobs: env: IS_NIGHTLY: ${{ needs.setup-release.outputs.nightly }} run: | - ARGS=() if [ "$IS_NIGHTLY" = "true" ]; then - ARGS+=(--config src-tauri/tauri.nightly.conf.json) + export SABLE_BUILD_FLAVOR=dev fi - pnpm tauri android build --apk --aab --target aarch64 armv7 "${ARGS[@]}" + pnpm tauri android build --apk --aab --target aarch64 armv7 OUT='src-tauri/gen/android/app/build/outputs' APK=$(find "$OUT/apk/universal/release" -name '*.apk' -type f | head -1) AAB=$(find "$OUT/bundle/universalRelease" -name '*.aab' -type f | head -1) @@ -440,7 +413,7 @@ jobs: TAG: ${{ needs.setup-release.outputs.tag }} VERSION: ${{ needs.setup-release.outputs.version }} VITE_APP_VERSION: ${{ needs.setup-release.outputs.version }} - VITE_IS_RELEASE_TAG: ${{ needs.setup-release.outputs.nightly == 'true' && 'true' || '' }} + SABLE_BUILD_FLAVOR: ${{ needs.setup-release.outputs.nightly == 'true' && 'dev' || 'stable' }} VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} VITE_SENTRY_ENVIRONMENT: ${{ needs.setup-release.outputs.nightly == 'true' && 'preview' || 'production' }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -489,11 +462,10 @@ jobs: env: IS_NIGHTLY: ${{ needs.setup-release.outputs.nightly }} run: | - ARGS=() if [ "$IS_NIGHTLY" = "true" ]; then - ARGS+=(--config src-tauri/tauri.nightly.conf.json) + export SABLE_BUILD_FLAVOR=dev fi - pnpm tauri ios build --no-sign --ci "${ARGS[@]}" + pnpm tauri ios build --no-sign --ci IPA=$(find src-tauri/gen/apple/build -name '*.ipa' -type f | head -1) [ -n "$IPA" ] || { echo "IPA not found"; ls -R src-tauri/gen/apple/build 2>/dev/null || true; exit 1; } NORMALIZED_IPA="$(dirname "$IPA")/Sable-${VERSION}-ios-arm64.ipa" @@ -556,7 +528,8 @@ jobs: TAG: ${{ needs.setup-release.outputs.tag }} VERSION: ${{ needs.setup-release.outputs.version }} VITE_APP_VERSION: ${{ needs.setup-release.outputs.version }} - VITE_IS_RELEASE_TAG: ${{ needs.setup-release.outputs.nightly == 'true' && 'true' || '' }} + SABLE_BUILD_FLAVOR: ${{ needs.setup-release.outputs.nightly == 'true' && 'dev' || 'stable' }} + SABLE_PRODUCT_NAME: ${{ needs.setup-release.outputs.product_name }} REPO: ${{ github.repository }} STARTED_AT: ${{ needs.setup-release.outputs.started_at }} steps: diff --git a/Dockerfile b/Dockerfile index 7376067c8e..7c6abd406f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,9 @@ FROM base AS builder WORKDIR /src ARG VITE_BUILD_HASH -ARG VITE_IS_RELEASE_TAG=false +ARG SABLE_BUILD_FLAVOR=stable ENV VITE_BUILD_HASH=$VITE_BUILD_HASH -ENV VITE_IS_RELEASE_TAG=$VITE_IS_RELEASE_TAG +ENV SABLE_BUILD_FLAVOR=$SABLE_BUILD_FLAVOR COPY pnpm-lock.yaml pnpm-workspace.yaml /src/ RUN pnpm fetch COPY . /src/ diff --git a/config.json b/config.json index 9abd8fd830..c44a213dfc 100644 --- a/config.json +++ b/config.json @@ -1,4 +1,5 @@ { + "productName": "Sable", "defaultHomeserver": 0, "homeserverList": ["matrix.org", "mozilla.org", "unredacted.org", "sable.moe", "kendama.moe"], "allowCustomHomeservers": true, diff --git a/docs/SENTRY_INTEGRATION.md b/docs/SENTRY_INTEGRATION.md index a926feb44e..1d2c0315a5 100644 --- a/docs/SENTRY_INTEGRATION.md +++ b/docs/SENTRY_INTEGRATION.md @@ -241,7 +241,7 @@ docker build \ - Set `VITE_SENTRY_ENVIRONMENT=production` - Gets 10% sampling for traces and session replay - Cost-effective for production usage -- Configured in `.github/workflows/cloudflare-web-deploy.yml` +- Configured in `.github/workflows/cloudflare-web.yml` **Preview deployments (PR previews, Cloudflare Pages):** diff --git a/flake.nix b/flake.nix index 3956dfcb6e..7960c6dbeb 100644 --- a/flake.nix +++ b/flake.nix @@ -240,7 +240,7 @@ nativeBuildInputs = pnpmNativeBuildInputs; env.VITE_BUILD_HASH = self.shortRev or self.dirtyShortRev or ""; - env.VITE_IS_RELEASE_TAG = "false"; + env.SABLE_BUILD_FLAVOR = "stable"; buildPhase = '' runHook preBuild diff --git a/infra/README.md b/infra/README.md index 3e01697cfe..b21d7ba3c4 100644 --- a/infra/README.md +++ b/infra/README.md @@ -97,8 +97,8 @@ npx wrangler versions upload Production deploys: -- `.github/workflows/cloudflare-web-deploy.yml` comments PR plans for `infra/web` changes. +- `.github/workflows/cloudflare-web.yml` comments PR plans for `infra/web` changes. - That PR plan job only runs for same-repo PRs, not fork PRs, because it needs repo secrets. -- The same workflow applies production on pushes to `dev` or manual dispatch. +- The workflow applies dev on pushes to `dev` or manual dispatch without tag; production on pushes to tags or manual dispatch with a tag. - `tofu apply` uploads `dist/` through `cloudflare_worker_version` and promotes it with `cloudflare_workers_deployment`. - Production lives on `app.sable.moe`. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9fe243b5b1..07b7be258a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,7 @@ name = "app_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] +serde_json = "1.0" tauri-build = { version = "2.6.3", features = [] } tauri-typegen = "0.5" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 27a87ba03d..36ed7391fb 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -14,6 +14,33 @@ fn main() { println!("cargo:rerun-if-env-changed=VITE_SENTRY_ENVIRONMENT"); println!("cargo:rerun-if-env-changed=VITE_APP_VERSION"); + let config_path = std::path::Path::new("../config.json"); + let base = std::fs::read_to_string(config_path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .and_then(|config| { + config + .get("productName") + .and_then(|v| v.as_str()) + .map(String::from) + }) + .unwrap_or_else(|| "Sable".into()); + + let full_name = if matches!( + std::env::var("SABLE_BUILD_FLAVOR").ok().as_deref(), + Some("dev" | "nightly") + ) { + format!("{} Nightly", base) + } else { + base + }; + + println!("cargo:rustc-env=SABLE_PRODUCT_NAME={}", full_name); + println!( + "cargo:rustc-env=TAURI_CONFIG={{\"productName\":\"{}\"}}", + full_name + ); + println!("cargo:rerun-if-env-changed=SABLE_BUILD_FLAVOR"); // Find libcef.so next to the binary (CEF ships it there). if std::env::var_os("CARGO_FEATURE_CEF").is_some() && std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") diff --git a/src-tauri/tauri.nightly.conf.json b/src-tauri/tauri.nightly.conf.json deleted file mode 100644 index e92fecd4e2..0000000000 --- a/src-tauri/tauri.nightly.conf.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", - "productName": "Sable Nightly" -} diff --git a/src/app/components/SwipeableChatWrapper.tsx b/src/app/components/SwipeableChatWrapper.tsx index 5d7d239b82..f2518f56d2 100644 --- a/src/app/components/SwipeableChatWrapper.tsx +++ b/src/app/components/SwipeableChatWrapper.tsx @@ -3,7 +3,7 @@ import { animate, motion, useMotionValue } from 'framer-motion'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom, RightSwipeAction } from '$state/settings'; import { haptic } from '$utils/haptics'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; import { useMobileNavDrawer } from '$components/page/MobileNavDrawerContext'; interface SwipeableChatWrapperProps { @@ -30,6 +30,8 @@ export function SwipeableChatWrapper({ x.stop(); } + if (!isMobileOrTablet()) return; + const canSwipeLeft = rightSwipeAction === RightSwipeAction.Members ? !!onOpenMembers : !!onReply; x.set(canSwipeLeft ? Math.max(-200, Math.min(0, distanceX)) : 0); @@ -56,7 +58,7 @@ export function SwipeableChatWrapper({ useLayoutEffect(() => { const element = containerRef.current; - if (!drawer || !element || !mobileOrTablet()) return undefined; + if (!drawer || !element || !isMobileOrTablet()) return undefined; return drawer.registerChatSwipe(element, { move, @@ -65,7 +67,7 @@ export function SwipeableChatWrapper({ }); }, [drawer, finish, move]); - if (!mobileOrTablet()) { + if (!isMobileOrTablet()) { return (
mobileOrTablet() && settings.rightSwipeAction !== RightSwipeAction.Members, + () => isMobileOrTablet() && settings.rightSwipeAction !== RightSwipeAction.Members, [settings.rightSwipeAction] ); diff --git a/src/app/components/SwipeableOverlayWrapper.tsx b/src/app/components/SwipeableOverlayWrapper.tsx index 3ca276f729..6efae98d2b 100644 --- a/src/app/components/SwipeableOverlayWrapper.tsx +++ b/src/app/components/SwipeableOverlayWrapper.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import { animate, motion, useMotionValue } from 'framer-motion'; import { useDrag } from '@use-gesture/react'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; interface SwipeableOverlayWrapperProps { children: ReactNode; @@ -25,7 +25,7 @@ export function SwipeableOverlayWrapper({ } } - if (!mobileOrTablet()) return; + if (!isMobileOrTablet()) return; event.stopPropagation(); @@ -65,7 +65,7 @@ export function SwipeableOverlayWrapper({ } ); - if (!mobileOrTablet()) { + if (!isMobileOrTablet()) { return (
({ useSetting: () => [false, vi.fn<() => void>()], })); -vi.mock('$utils/user-agent', () => ({ - mobileOrTablet: () => false, +vi.mock('$utils/platform', () => ({ + isMobileOrTablet: () => false, })); const callbacks = () => ({ diff --git a/src/app/components/attachment-sheet/AttachmentSheet.tsx b/src/app/components/attachment-sheet/AttachmentSheet.tsx index 31dcdcb733..f2c57478d6 100644 --- a/src/app/components/attachment-sheet/AttachmentSheet.tsx +++ b/src/app/components/attachment-sheet/AttachmentSheet.tsx @@ -4,7 +4,7 @@ import { useDrag } from '@use-gesture/react'; import FocusTrap from 'focus-trap-react'; import { useAndroidBackHandler } from '$utils/androidBack'; import { stopPropagation } from '$utils/keyboard'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; import { getMobileSheetTiming, useMobileSheetAnimation } from '$components/mobileSheetAnimation'; import * as animationCss from '$components/mobileSheetAnimation.css'; import type { Icon } from '@phosphor-icons/react'; @@ -66,7 +66,7 @@ export function AttachmentSheet({ return true; }, open); - const gesturesEnabled = mobileOrTablet(); + const gesturesEnabled = isMobileOrTablet(); const bind = useDrag( ({ first, active, offset: [, oy], velocity: [, vy], direction: [, dy], event }) => { diff --git a/src/app/components/editor/Editor.test.tsx b/src/app/components/editor/Editor.test.tsx index 600770c226..711f724cab 100644 --- a/src/app/components/editor/Editor.test.tsx +++ b/src/app/components/editor/Editor.test.tsx @@ -11,7 +11,7 @@ let measurementCacheScrollHeightReads = 0; let isIosApp = false; let nativeClipboardText = ''; -vi.mock(import('$utils/user-agent'), async (importOriginal) => ({ +vi.mock(import('$utils/platform'), async (importOriginal) => ({ ...(await importOriginal()), iosApp: () => isIosApp, })); diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 447ebc7a0a..11b05b4430 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -11,7 +11,7 @@ import { Node, Transforms, createEditor } from 'slate'; import type { RenderLeafProps, RenderElementProps, RenderPlaceholderProps } from 'slate-react'; import { Slate, Editable, withReact, ReactEditor } from 'slate-react'; import { withHistory } from 'slate-history'; -import { iosApp, mobileOrTablet } from '$utils/user-agent'; +import { iosApp, isMobileOrTablet } from '$utils/platform'; import { readClipboardText } from '$utils/dom'; import { createLogger } from '$utils/debug'; import { BlockType } from './types'; @@ -490,7 +490,7 @@ export const CustomEditor = forwardRef( // keeps focus after pressing send, but yields to another editor. onBlur={(evt) => { cancelFocusScroll(); - if (!mobileOrTablet()) return; + if (!isMobileOrTablet()) return; if (suppressBlurRefocusRef?.current) return; const next = evt.relatedTarget as HTMLElement | null; if (next && next !== editableRef.current && next.isContentEditable) return; @@ -499,7 +499,7 @@ export const CustomEditor = forwardRef( // Once the virtual keyboard has settled, make sure the composer is // not left hidden behind it. onFocus={() => { - if (!mobileOrTablet()) return; + if (!isMobileOrTablet()) return; cancelFocusScroll(); const scrollIn = () => { if (editableRef.current?.contains(document.activeElement)) { diff --git a/src/app/components/emoji-board/components/SearchInput.tsx b/src/app/components/emoji-board/components/SearchInput.tsx index 5d55b061b9..cabcab0fab 100644 --- a/src/app/components/emoji-board/components/SearchInput.tsx +++ b/src/app/components/emoji-board/components/SearchInput.tsx @@ -1,7 +1,7 @@ import type { ChangeEventHandler } from 'react'; import { useRef } from 'react'; import { Input, Chip, Text } from 'folds'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; import { ArrowRight, sizedIcon, MagnifyingGlass } from '$components/icons/phosphor'; import { EmojiBoardTab } from '../types'; @@ -56,7 +56,7 @@ export function SearchInput({ ) } onChange={onChange} - autoFocus={!mobileOrTablet()} + autoFocus={!isMobileOrTablet()} /> ); } diff --git a/src/app/components/sidebar/SidebarItem.tsx b/src/app/components/sidebar/SidebarItem.tsx index c0ceb57370..486b661811 100644 --- a/src/app/components/sidebar/SidebarItem.tsx +++ b/src/app/components/sidebar/SidebarItem.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames'; import type { Position } from 'folds'; import { as, Avatar, Text, Tooltip, TooltipProvider, toRem } from 'folds'; import type { ComponentProps, ReactNode, RefCallback } from 'react'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; import * as css from './Sidebar.css'; const SidebarItemBottom = as<'div', css.SidebarItemVariants>( @@ -70,7 +70,7 @@ export function SidebarItemTooltip({ children: (triggerRef: RefCallback) => ReactNode; position?: Position; }) { - if (!tooltip || mobileOrTablet()) { + if (!tooltip || isMobileOrTablet()) { return children(() => undefined); } diff --git a/src/app/components/tauri/DesktopTitleBar.tsx b/src/app/components/tauri/DesktopTitleBar.tsx index a20af2f3d0..84bec1d170 100644 --- a/src/app/components/tauri/DesktopTitleBar.tsx +++ b/src/app/components/tauri/DesktopTitleBar.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { getVersion } from '@tauri-apps/api/app'; import { isTauri } from '@tauri-apps/api/core'; import { getCurrentWindow, type Window } from '@tauri-apps/api/window'; import { listen } from '@tauri-apps/api/event'; @@ -112,9 +111,13 @@ export function DesktopTitleBar() { } }, [isWindows]); + const title = SABLE_PRODUCT_NAME; + useEffect(() => { if (!isDesktopTitleBar) return undefined; + setWindowTitle(title); + const appWindow = getCurrentWindow(); appWindowRef.current = appWindow; @@ -122,14 +125,6 @@ export function DesktopTitleBar() { let unlistenResize: (() => void) | undefined; let unlistenTracking: (() => void) | undefined; - getVersion() - .then((version) => { - if (mounted) setWindowTitle(version.includes('-nightly.') ? 'Sable Nightly' : 'Sable'); - }) - .catch((error) => { - log.warn('Failed to read app version for window title:', error); - }); - const syncMaximized = async () => { try { const isMaximizedState = await appWindow.isMaximized(); @@ -182,7 +177,7 @@ export function DesktopTitleBar() { unlistenResize?.(); unlistenTracking?.(); }; - }, [isDesktopTitleBar, isWindows, hideSnapOverlay]); + }, [isDesktopTitleBar, isWindows, hideSnapOverlay, title]); if (!isDesktopTitleBar) return null; diff --git a/src/app/components/upload-card/UploadDescriptionEditor.tsx b/src/app/components/upload-card/UploadDescriptionEditor.tsx index 9f6531f584..59bccb0c45 100644 --- a/src/app/components/upload-card/UploadDescriptionEditor.tsx +++ b/src/app/components/upload-card/UploadDescriptionEditor.tsx @@ -29,7 +29,7 @@ import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { UseStateProvider } from '$components/UseStateProvider'; import { EmojiBoard } from '$components/emoji-board'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; import * as css from './UploadDescriptionEditor.css'; type DescriptionEditorProps = { @@ -214,7 +214,7 @@ export function DescriptionEditor({ requestClose={() => setAnchor((v) => { if (v) { - if (!mobileOrTablet()) ReactEditor.focus(editor); + if (!isMobileOrTablet()) ReactEditor.focus(editor); return undefined; } return v; diff --git a/src/app/features/room/CallChatView.tsx b/src/app/features/room/CallChatView.tsx index bdd23c3fef..9b9ca03800 100644 --- a/src/app/features/room/CallChatView.tsx +++ b/src/app/features/room/CallChatView.tsx @@ -9,7 +9,7 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; import { useState, useEffect } from 'react'; export function CallChatView() { @@ -34,7 +34,7 @@ export function CallChatView() { flexGrow: 0, }} > - {!mobileOrTablet() && ( + {!isMobileOrTablet() && ( ( setUploadBoard(true); const safeFiles = await Promise.all(files.map(safeUploadFile)); // Eager-read to avoid Android content URI expiry after SAF picker - const blobbedFiles = mobileOrTablet() + const blobbedFiles = isMobileOrTablet() ? await Promise.all( safeFiles.map(async (f) => { try { @@ -588,7 +588,7 @@ export const RoomInput = forwardRef( const prevEditingEventId = useRef(); const preEditDraftRef = useRef(); useEffect(() => { - if (!mobileOrTablet()) { + if (!isMobileOrTablet()) { prevEditingEventId.current = undefined; preEditDraftRef.current = undefined; return; @@ -1034,7 +1034,7 @@ export const RoomInput = forwardRef( ); const submit = useCallback(async () => { - if (editingEvent && mobileOrTablet()) { + if (editingEvent && isMobileOrTablet()) { let plainText = toPlainText(editor.children).trim(); if (!plainText) { onCancelEdit?.(); @@ -1566,7 +1566,7 @@ export const RoomInput = forwardRef( } if (isKeyHotkey('escape', evt)) { evt.preventDefault(); - if (editingEvent && mobileOrTablet()) { + if (editingEvent && isMobileOrTablet()) { onCancelEdit?.(); resetEditor(editor); resetEditorHistory(editor); @@ -1917,7 +1917,7 @@ export const RoomInput = forwardRef(
)} - {editingEvent && mobileOrTablet() && ( + {editingEvent && isMobileOrTablet() && (
( } before={ <> - {mobileOrTablet() ? ( + {isMobileOrTablet() ? ( <> setShowAttachmentSheet(true)} @@ -2176,7 +2176,7 @@ export const RoomInput = forwardRef( onTabChange={setEmojiBoardTab} imagePackRooms={imagePackRooms} returnFocusOnDeactivate={false} - isFullWidth={mobileOrTablet()} + isFullWidth={isMobileOrTablet()} onEmojiSelect={handleEmoticonSelect} onCustomEmojiSelect={handleEmoticonSelect} onStickerSelect={handleStickerSelect} @@ -2251,7 +2251,7 @@ export const RoomInput = forwardRef( })} ); - if (mobileOrTablet()) { + if (isMobileOrTablet()) { return ( <> {triggers} @@ -2342,7 +2342,7 @@ export const RoomInput = forwardRef( return; } if (!editorMicButton) return; - if (mobileOrTablet()) return; + if (isMobileOrTablet()) return; setShowAudioRecorder(true); }} onMouseDown={(e: MouseEvent) => { @@ -2352,7 +2352,7 @@ export const RoomInput = forwardRef( if (showAudioRecorder) return; if (hasContent) { isLongPress.current = false; - if (mobileOrTablet() && delayedEventsSupported) { + if (isMobileOrTablet() && delayedEventsSupported) { longPressTimer.current = setTimeout(() => { isLongPress.current = true; setShowSchedulePicker(true); @@ -2361,7 +2361,7 @@ export const RoomInput = forwardRef( return; } if (!editorMicButton) return; - if (!mobileOrTablet()) return; + if (!isMobileOrTablet()) return; micHoldStartRef.current = Date.now(); setShowAudioRecorder(true); @@ -2467,7 +2467,7 @@ export const RoomInput = forwardRef( } /> - {delayedEventsSupported && !mobileOrTablet() && ( + {delayedEventsSupported && !isMobileOrTablet() && ( ) => { setScheduleMenuAnchor(evt.currentTarget.getBoundingClientRect()); diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 9b19960846..de6e852c2c 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -13,7 +13,7 @@ import { useEditor, resetEditor } from '$components/editor'; import { Page } from '$components/page'; import { useKeyDown } from '$hooks/useKeyDown'; import { editableActiveElement } from '$utils/dom'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; @@ -99,7 +99,7 @@ export function RoomView({ eventId }: { eventId?: string }) { const { editId, handleEdit } = useMessageEdit(editor, { onReset: handleResetEditor, alive, - focusOnCancel: !mobileOrTablet(), + focusOnCancel: !isMobileOrTablet(), }); useDelayedEventsSupport(); diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx index b7d9e0e849..1622e4b15a 100644 --- a/src/app/features/room/ThreadBrowser.tsx +++ b/src/app/features/room/ThreadBrowser.tsx @@ -19,7 +19,7 @@ import { MessagePreview, useRoomMessagePreviewRenderer } from '$components/messa import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; import * as css from './ThreadDrawer.css'; import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; import { useMatrixEvent } from '$hooks/useMatrixEvent'; type ThreadPreviewProps = { @@ -315,7 +315,7 @@ export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBr width: overlay ? '100%' : toRem(curWidth), }} > - {!mobileOrTablet() && ( + {!isMobileOrTablet() && ( - {!mobileOrTablet() && ( + {!isMobileOrTablet() && ( { - if (!mobileOrTablet()) setIsDesktopHover(h); + if (!isMobileOrTablet()) setIsDesktopHover(h); }, }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: (f) => { - if (!mobileOrTablet()) setIsDesktopHover(f); + if (!isMobileOrTablet()) setIsDesktopHover(f); }, }); @@ -785,7 +785,7 @@ function MessageInternal( )} {reply} - {edit && onEditId && !mobileOrTablet() ? ( + {edit && onEditId && !isMobileOrTablet() ? ( ( }); editor.insertFragment(initialValue); - if (!mobileOrTablet()) ReactEditor.focus(editor); + if (!isMobileOrTablet()) ReactEditor.focus(editor); }, [editor, getPrevBodyAndFormattedBody, room, nicknames, mx]); useEffect(() => { @@ -560,13 +560,13 @@ export const MessageEditor = as<'div', MessageEditorProps>( { setAnchor((v) => { if (v) { - if (!mobileOrTablet()) ReactEditor.focus(editor); + if (!isMobileOrTablet()) ReactEditor.focus(editor); return undefined; } return v; @@ -593,7 +593,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( })} ); - if (mobileOrTablet()) { + if (isMobileOrTablet()) { return ( <> {trigger} diff --git a/src/app/features/room/message/Reactions.tsx b/src/app/features/room/message/Reactions.tsx index f4e3b55f15..ef5fa9e065 100644 --- a/src/app/features/room/message/Reactions.tsx +++ b/src/app/features/room/message/Reactions.tsx @@ -22,7 +22,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { factoryEventSentBy } from '$utils/matrix'; import { Reaction, ReactionTooltipMsg } from '$components/message'; import { EmojiBoard } from '$components/emoji-board'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; import { sizedIcon, Smiley } from '$components/icons/phosphor'; import { useRelations } from '$hooks/useRelations'; import { stopPropagation } from '$utils/keyboard'; @@ -134,7 +134,7 @@ export const Reactions = as<'div', ReactionsProps>( imagePackRooms={imagePackRooms ?? []} returnFocusOnDeactivate={false} allowTextCustomEmoji - isFullWidth={mobileOrTablet()} + isFullWidth={isMobileOrTablet()} onEmojiSelect={(key) => { onReactionToggle(mEventId, key); setEmojiBoardAnchor(undefined); @@ -170,7 +170,7 @@ export const Reactions = as<'div', ReactionsProps>( )} ); - if (mobileOrTablet()) { + if (isMobileOrTablet()) { return ( <> {trigger} diff --git a/src/app/features/room/persona-picker/PersonaPicker.tsx b/src/app/features/room/persona-picker/PersonaPicker.tsx index 400df94848..3df46e8c2b 100644 --- a/src/app/features/room/persona-picker/PersonaPicker.tsx +++ b/src/app/features/room/persona-picker/PersonaPicker.tsx @@ -16,7 +16,7 @@ import { } from '$hooks/usePerMessageProfile'; import { stopPropagation } from '$utils/keyboard'; import { mxcUrlToHttp } from '$utils/matrix.ts'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; import FocusTrap from 'focus-trap-react'; import { nameInitials } from '$utils/common'; import { @@ -203,7 +203,7 @@ export function PersonaPicker({ size="400" placeholder="Search" maxLength={50} - autoFocus={!mobileOrTablet()} + autoFocus={!isMobileOrTablet()} onChange={filter} before={menuIcon(MagnifyingGlass)} /> diff --git a/src/app/features/settings/account/BioEditor.tsx b/src/app/features/settings/account/BioEditor.tsx index 432cf53331..ce9aa29140 100644 --- a/src/app/features/settings/account/BioEditor.tsx +++ b/src/app/features/settings/account/BioEditor.tsx @@ -29,7 +29,7 @@ import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { UseStateProvider } from '$components/UseStateProvider'; import { EmojiBoard } from '$components/emoji-board'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; import { SettingTile } from '$components/setting-tile'; import * as css from './BioEditor.css'; @@ -200,7 +200,7 @@ export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditor requestClose={() => setAnchor((v) => { if (v) { - if (!mobileOrTablet()) ReactEditor.focus(editor); + if (!isMobileOrTablet()) ReactEditor.focus(editor); return undefined; } return v; diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 6a49651748..1856e3f56d 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -55,7 +55,7 @@ import type { EditorButtonId } from '$state/settings'; import { MessageLayout, RightSwipeAction, settingsAtom } from '$state/settings'; import { SettingTile, SettingToggle } from '$components/setting-tile'; import { KeySymbol } from '$utils/key-symbol'; -import { isMacOS, mobileOrTablet } from '$utils/user-agent'; +import { isMacOS, isMobileOrTablet } from '$utils/platform'; import { stopPropagation } from '$utils/keyboard'; import { sessionsAtom, activeSessionIdAtom } from '$state/sessions'; import { isKeyHotkey } from 'is-hotkey'; @@ -1358,7 +1358,7 @@ export function General({ requestBack, requestClose }: Readonly) { - + diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 316e14ab72..3d78278891 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -26,7 +26,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { useClientConfig } from '$hooks/useClientConfig'; import { pushSubscriptionAtom } from '$state/pushSubscription'; import { unifiedPushEndpointAtom, type UnifiedPushState } from '$state/unifiedPushEndpoint'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; import { isIosTauri } from '$features/settings/notifications/TauriNotificationsApiClient'; import { isTauri } from '@tauri-apps/api/core'; import { type as osType } from '@tauri-apps/plugin-os'; @@ -953,7 +953,7 @@ export function SystemNotification() { value={showInAppNotifs} onChange={setShowInAppNotifs} /> - {(!mobileOrTablet() || isIosTauri()) && ( + {(!isMobileOrTablet() || isIosTauri()) && ( Promise | void; }; @@ -135,10 +135,10 @@ async function ensureTauriNotificationPermission(): Promise { return permissionPromise; } -export const isIosTauri = (): boolean => isTauri() && osType() === 'ios'; -export const isAndroidTauri = (): boolean => isTauri() && osType() === 'android'; // Platforms where OS notifications go through the native plugin instead of web APIs. -export const isNativeNotificationTauri = (): boolean => isDesktopTauri() || isIosTauri(); +export const isNativeNotificationTauri = (): boolean => isDesktopTauri() || isMobileTauri(); + +export const isIosTauri = (): boolean => isTauri() && osType() === 'ios'; let nativeNotificationSeq = 1; const nextNativeNotificationId = (): number => { @@ -187,4 +187,4 @@ export async function sendNativeTauriNotification({ }); } -export { isDesktopTauri }; +export { isDesktopTauri, isMobileTauri, isAndroidTauri }; diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.ts index e094b71742..dc8a9a3718 100644 --- a/src/app/features/settings/notifications/UnifiedPushNotifications.ts +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.ts @@ -24,11 +24,7 @@ import { } from './UnifiedPushMessageListener'; import { addPluginListener } from '@tauri-apps/api/core'; import type { PushTransportConfig } from './NotificationTransport'; -import { - getTauriNotificationsApi, - isAndroidTauri, - isIosTauri, -} from './TauriNotificationsApiClient'; +import { getTauriNotificationsApi, isMobileTauri } from './TauriNotificationsApiClient'; import { resolvePushNotifyUrl, withPushPayloadFormat, @@ -460,7 +456,7 @@ async function postRoomNotification( silent: isSilent, autoCancel: true, extra, - ...(isAndroidTauri() || isIosTauri() ? { actionTypeId: 'sable-message' } : {}), + ...(isMobileTauri() ? { actionTypeId: 'sable-message' } : {}), inboxLines: inboxLines.length > 1 ? inboxLines : undefined, largeBody: inboxLines.length > 1 ? undefined : latestBody, }); diff --git a/src/app/features/widgets/WidgetsDrawer.tsx b/src/app/features/widgets/WidgetsDrawer.tsx index 321db40529..1a2ce11da6 100644 --- a/src/app/features/widgets/WidgetsDrawer.tsx +++ b/src/app/features/widgets/WidgetsDrawer.tsx @@ -41,7 +41,7 @@ import * as css from './WidgetsDrawer.css'; import { IntegrationManager } from './IntegrationManager'; import { CustomStateEvent } from '$types/matrix/room'; import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; type WidgetsDrawerHeaderProps = { activeWidget: RoomWidget | null; @@ -268,10 +268,10 @@ export function WidgetsDrawer({ room }: WidgetsDrawerProps) { direction="Column" style={{ position: 'relative', - width: !mobileOrTablet() ? toRem(curWidth) : 'inherit', + width: !isMobileOrTablet() ? toRem(curWidth) : 'inherit', }} > - {!mobileOrTablet() && ( + {!isMobileOrTablet() && ( { const handleVisibilityChange = () => { diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index cf9965b4ce..ec39ec2582 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -1,4 +1,5 @@ import { Box, Text } from 'folds'; +import { versionLabel } from '$utils/platform'; import * as css from './styles.css'; export function AuthFooter() { @@ -14,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - {`v${APP_VERSION}${IS_RELEASE_TAG ? '' : `-dev${BUILD_HASH ? ` (${BUILD_HASH})` : ''}`}`} + {versionLabel()} Powered by Matrix diff --git a/src/app/pages/auth/SSOTauri.ts b/src/app/pages/auth/SSOTauri.ts index 11fb3d2213..5f6ea3284e 100644 --- a/src/app/pages/auth/SSOTauri.ts +++ b/src/app/pages/auth/SSOTauri.ts @@ -10,12 +10,6 @@ const getAppBaseUrl = (): string => { return `${TAURI_SSO_PROTOCOL}//${TAURI_SSO_HOST}`; } - if (import.meta.env.DEV) { - // TODO: disabled for now since it causes issues with the SSO flow. We should find a better solution for this in the future. - // return window.location.origin; - return `${TAURI_SSO_PROTOCOL}//${TAURI_SSO_HOST}`; - } - return `${TAURI_SSO_PROTOCOL}//${TAURI_SSO_HOST}`; }; diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 943daf94e1..f573f35abb 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -30,7 +30,7 @@ import { PasswordInput } from '$components/password-input'; import { getResetPasswordPath } from '$pages/pathUtils'; import { stopPropagation } from '$utils/keyboard'; import { FieldError } from '$pages/auth/FiledError'; -import { deviceDisplayName } from '$utils/user-agent'; +import { deviceDisplayName } from '$utils/platform'; import { getMxIdServer } from '$utils/mxIdHelper'; import type { CustomLoginResponse } from './loginUtil'; import { LoginError, factoryGetBaseUrl, login, useLoginComplete } from './loginUtil'; diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx index 3744748c13..0ab5e8fa1f 100644 --- a/src/app/pages/auth/login/TokenLogin.tsx +++ b/src/app/pages/auth/login/TokenLogin.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect } from 'react'; import type { MatrixError } from '$types/matrix-sdk'; import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; -import { deviceDisplayName } from '$utils/user-agent'; +import { deviceDisplayName } from '$utils/platform'; import type { CustomLoginResponse } from './loginUtil'; import { LoginError, login, useLoginComplete } from './loginUtil'; diff --git a/src/app/pages/auth/register/PasswordRegisterForm.tsx b/src/app/pages/auth/register/PasswordRegisterForm.tsx index dcd355647d..f4c57fa539 100644 --- a/src/app/pages/auth/register/PasswordRegisterForm.tsx +++ b/src/app/pages/auth/register/PasswordRegisterForm.tsx @@ -37,7 +37,7 @@ import { ConfirmPasswordMatch } from '$components/ConfirmPasswordMatch'; import { UIAFlowOverlay } from '$components/UIAFlowOverlay'; import type { RequestEmailTokenCallback, RequestEmailTokenResponse } from '$hooks/types'; import { FieldError } from '$pages/auth/FiledError'; -import { deviceDisplayName } from '$utils/user-agent'; +import { deviceDisplayName } from '$utils/platform'; import { fetch } from '$utils/fetch'; import type { RegisterResult } from './registerUtil'; import { RegisterError, register, useRegisterComplete } from './registerUtil'; diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 128d42d110..a033d63df9 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -44,7 +44,7 @@ import * as Sentry from '@sentry/react'; import { startClient, stopClient } from '$client/initMatrix'; import { createSessionTokenRefresher } from '$client/oidcTokenRefresher'; import { isDesktopTauri } from '$utils/platform'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; const log = createLogger('BackgroundNotifications'); const debugLog = createDebugLogger('BackgroundNotifications'); @@ -511,7 +511,7 @@ export function BackgroundNotifications() { // Show in-app banner when app is visible, mobile, and in-app notifications enabled const canShowInAppBanner = document.visibilityState === 'visible' && - mobileOrTablet() && + isMobileOrTablet() && showNotificationsRef.current; if (canShowInAppBanner) { diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 4e62021ea8..70ee8bffba 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -1,6 +1,7 @@ import { Box, Button, Text, config, toRem } from 'folds'; import { Code, Heart, menuIcon } from '$components/icons/phosphor'; import { Page, PageHero, PageHeroSection } from '$components/page'; +import { versionLabel } from '$utils/platform'; import LogoSVG from '$public/res/svg/logo.svg'; export function WelcomePage() { @@ -24,7 +25,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - {`v${APP_VERSION}${IS_RELEASE_TAG ? '' : `-dev${BUILD_HASH ? ` (${BUILD_HASH})` : ''}`}`} + {versionLabel()} } diff --git a/src/app/pages/client/client-non-ui/notifications.tsx b/src/app/pages/client/client-non-ui/notifications.tsx index 15d784cc83..b5aeb1b453 100644 --- a/src/app/pages/client/client-non-ui/notifications.tsx +++ b/src/app/pages/client/client-non-ui/notifications.tsx @@ -52,7 +52,7 @@ import { buildRoomMessageNotification, resolveNotificationPreviewText, } from '$utils/notificationStyle'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isMobileOrTablet } from '$utils/platform'; import { createDebugLogger } from '$utils/debugLogger'; import { showToast } from '$state/toast'; import { @@ -133,7 +133,7 @@ export function InviteNotifications() { // OS notification for invites — desktop, plus iOS while foregrounded (testing). if ( - (!mobileOrTablet() || isIosTauri()) && + (!isMobileOrTablet() || isIosTauri()) && showSystemNotifications && (isNativeNotificationTauri() || notificationPermission('granted')) ) { @@ -343,7 +343,7 @@ export function MessageNotifications() { const withSound = notificationSound && isLoud && (tabVisible || backgroundNotificationSounds); let soundOnNotification = false; if ( - (!mobileOrTablet() || isIosTauri()) && + (!isMobileOrTablet() || isIosTauri()) && showSystemNotifications && (isNativeNotificationTauri() || notificationPermission('granted')) ) { diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index b23f076525..443aa1c494 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -1,6 +1,7 @@ import { atom, type WritableAtom } from 'jotai'; import type { Store } from 'jotai/vanilla/store'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isNightly } from '$utils/platform'; +import { isMobileOrTablet } from '$utils/platform'; import type { NotificationTransportMode, NotificationTransportProvider, @@ -298,7 +299,7 @@ export const defaultSettings: Settings = { isPeopleDrawer: true, isWidgetDrawer: false, memberSortFilterIndex: 0, - enterForNewline: mobileOrTablet(), + enterForNewline: isMobileOrTablet(), editorToolbar: false, editorOldAddFile: false, editorMicButton: true, @@ -338,10 +339,10 @@ export const defaultSettings: Settings = { // Push notifications (SW/Sygnal): default on for mobile, opt-in on desktop. // In-app pill banner: default on for mobile (primary foreground alert), opt-in on desktop. // System (OS) notifications: desktop-only; hidden and disabled on mobile. - usePushNotifications: mobileOrTablet(), + usePushNotifications: isMobileOrTablet(), useUnifiedPush: false, - useInAppNotifications: mobileOrTablet(), - useSystemNotifications: !mobileOrTablet(), + useInAppNotifications: isMobileOrTablet(), + useSystemNotifications: !isMobileOrTablet(), isNotificationSounds: true, backgroundNotificationSounds: true, showMessageContentInNotifications: false, @@ -349,7 +350,7 @@ export const defaultSettings: Settings = { useRichPushPayloads: true, pushNotifyUrlOverride: undefined, clearNotificationsOnRead: false, - backgroundPushEnabled: mobileOrTablet(), + backgroundPushEnabled: isMobileOrTablet(), backgroundPushProvider: null, pushTransportMode: 'auto', pushTransportOverride: {}, @@ -371,7 +372,7 @@ export const defaultSettings: Settings = { privacyBlurEmotes: false, showPronouns: true, parsePronouns: true, - pronounPillMaxCount: mobileOrTablet() ? 1 : 3, + pronounPillMaxCount: isMobileOrTablet() ? 1 : 3, pronounPillMaxLength: 16, renderGlobalNameColors: true, renderUserCards: 'both', @@ -771,7 +772,7 @@ export function sanitizeSettingsDefaults(raw: unknown): Partial { } } - if (import.meta.env.DEV && warnings.length > 0) { + if (isNightly() && warnings.length > 0) { console.warn( '[config.settingsDefaults] ignored unknown or invalid keys:', [...new Set(warnings)].slice(0, 25).join(', ') diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts index 6347df784f..51d514efbe 100644 --- a/src/app/utils/debugLogger.ts +++ b/src/app/utils/debugLogger.ts @@ -6,6 +6,7 @@ */ import * as Sentry from '@sentry/react'; +import { versionLabel } from '$utils/platform'; import { sanitizeSentryPayload } from './sentryScrubbers'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; @@ -292,7 +293,7 @@ class DebugLoggerService { return JSON.stringify( { exportedAt: new Date().toISOString(), - build: `v${APP_VERSION}${BUILD_HASH ? ` (${BUILD_HASH})` : ''}`, + build: versionLabel({ includeNightly: false }), logsCount: this.logs.length, logs: this.logs.map((log) => ({ ...log, diff --git a/src/app/utils/platform.ts b/src/app/utils/platform.ts index 885288dd08..9c278c8857 100644 --- a/src/app/utils/platform.ts +++ b/src/app/utils/platform.ts @@ -1,20 +1,67 @@ import { isTauri } from '@tauri-apps/api/core'; import { type as osType } from '@tauri-apps/plugin-os'; +import { UAParser } from 'ua-parser-js'; + +export const ua = new UAParser(window.navigator.userAgent).getResult(); + +let tauriOSResolved = false; +let tauriOSValue: string | undefined; + +function getTauriOS(): string | undefined { + if (!tauriOSResolved) { + tauriOSResolved = true; + if (isTauri()) { + try { + tauriOSValue = osType(); + } catch { + tauriOSValue = undefined; + } + } + } + return tauriOSValue; +} + +const MOBILE_TAURI_OS = ['android', 'ios'] as const; +const DESKTOP_TAURI_OS = ['linux', 'macos', 'windows'] as const; + +export function isMobileOrTablet(): boolean { + const tauriOS = getTauriOS(); + if (tauriOS && (MOBILE_TAURI_OS as readonly string[]).includes(tauriOS)) return true; + + const { os, device } = ua; + if (device.type === 'mobile' || device.type === 'tablet') return true; + if (os.name === 'Android' || os.name === 'iOS') return true; + return false; +} + +export function isMacOS(): boolean { + return ua.os.name === 'Mac OS' || ua.os.name === 'macOS'; +} + +export function iosApp(): boolean { + return getTauriOS() === 'ios'; +} + +export function isNightly(): boolean { + return SABLE_BUILD_FLAVOR === 'dev'; +} + +export function versionLabel(opts?: { includeNightly?: boolean }): string { + const dev = (opts?.includeNightly ?? true) && isNightly() ? '-dev' : ''; + const hash = BUILD_HASH ? ` (${BUILD_HASH})` : ''; + return `v${APP_VERSION}${dev}${hash}`; +} export function hasServiceWorker(): boolean { - // Android WebViews (Tauri) do not support service workers. return 'serviceWorker' in navigator && !isTauri(); } -export type DesktopTauriPlatform = 'linux' | 'macos' | 'windows'; - -const DESKTOP_TAURI_OS = new Set(['linux', 'macos', 'windows']); +export type DesktopTauriPlatform = (typeof DESKTOP_TAURI_OS)[number]; export function getDesktopTauriPlatform(): DesktopTauriPlatform | undefined { - if (!isTauri()) return undefined; - const os = osType() as string; - return DESKTOP_TAURI_OS.has(os as DesktopTauriPlatform) - ? (os as DesktopTauriPlatform) + const tauriOS = getTauriOS(); + return (DESKTOP_TAURI_OS as readonly string[]).includes(tauriOS ?? '') + ? (tauriOS as DesktopTauriPlatform) : undefined; } @@ -22,11 +69,19 @@ export function isDesktopTauri(): boolean { return getDesktopTauriPlatform() !== undefined; } +export function isMobileTauri(): boolean { + const tauriOS = getTauriOS(); + return tauriOS === 'ios' || tauriOS === 'android'; +} + +export function isAndroidTauri(): boolean { + return getTauriOS() === 'android'; +} + export function hasControllingServiceWorker(): boolean { return hasServiceWorker() && navigator.serviceWorker.controller !== null; } -// window.location.origin is "null" on Tauri (tauri:// is opaque per WHATWG). export function getAppOrigin(): string { if ( isTauri() || @@ -47,3 +102,52 @@ export function getWindowOrigin(): string { ? `${window.location.protocol}//${window.location.host}` : window.location.origin; } + +type FormFactor = 'Web' | 'Desktop' | 'Mobile'; + +// Display names for every known Tauri OS value. Kept exhaustive (rather than relying on capitalize() as a silent fallback) so a new OS is a visible gap. +const TAURI_OS_DISPLAY_NAMES: Record = { + linux: 'Linux', + macos: 'macOS', + windows: 'Windows', + android: 'Android', + ios: 'iOS', +}; + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function normalizeOSName(os: string): string { + return os === 'Mac OS' ? 'macOS' : os; +} + +function variantLabel(): string { + return isNightly() ? ' (Nightly)' : ''; +} + +function formFactor(): FormFactor { + if (!isTauri()) return 'Web'; + const tauriOS = getTauriOS(); + if (tauriOS && (MOBILE_TAURI_OS as readonly string[]).includes(tauriOS)) return 'Mobile'; + return 'Desktop'; +} + +function osSpec(): string { + if (!isTauri()) { + const browser = ua.browser.name; + const os = ua.os.name; + if (browser && os) return `${browser} for ${normalizeOSName(os)}`; + if (browser) return browser; + if (os) return normalizeOSName(os); + return 'Web'; + } + const tauriOS = getTauriOS(); + if (tauriOS) return TAURI_OS_DISPLAY_NAMES[tauriOS] ?? capitalize(tauriOS); + const os = ua.os.name; + return os ? normalizeOSName(os) : 'Desktop'; +} + +export function deviceDisplayName(): string { + return `${SABLE_PRODUCT_NAME} ${formFactor()}${variantLabel()} on ${osSpec()}`; +} diff --git a/src/app/utils/user-agent.ts b/src/app/utils/user-agent.ts deleted file mode 100644 index d017364991..0000000000 --- a/src/app/utils/user-agent.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { isTauri } from '@tauri-apps/api/core'; -import { type as osType } from '@tauri-apps/plugin-os'; -import { UAParser } from 'ua-parser-js'; - -const result = new UAParser(window.navigator.userAgent).getResult(); - -const isMobileOrTablet = (() => { - if (isTauri()) { - try { - const tauriOs = osType(); - if (tauriOs === 'android' || tauriOs === 'ios') return true; - } catch { - // Fallback to UA parsing if plugin-os is not ready/available - } - } - const { os, device } = result; - if (device.type === 'mobile' || device.type === 'tablet') return true; - if (os.name === 'Android' || os.name === 'iOS') return true; - return false; -})(); - -const normalizeMacName = (os?: string) => { - if (!os) return os; - if (os === 'Mac OS') return 'macOS'; - return os; -}; - -const isMac = result.os.name === 'Mac OS' || result.os.name === 'macOS'; - -const isIosApp = (() => { - if (!isTauri()) return false; - try { - return osType() === 'ios'; - } catch { - return false; - } -})(); - -export const isMacOS = () => isMac; -export const iosApp = () => isIosApp; -export const mobileOrTablet = () => isMobileOrTablet; - -export const deviceDisplayName = (): string => { - const browser = result.browser.name; - const os = normalizeMacName(result.os.name); - if (!browser || !os) return 'Sable Web'; - return `Sable on ${browser} for ${os}`; -}; diff --git a/src/ext.d.ts b/src/ext.d.ts index 7ee0a20a8b..2c77fa0a5d 100644 --- a/src/ext.d.ts +++ b/src/ext.d.ts @@ -1,5 +1,7 @@ /// +declare const SABLE_PRODUCT_NAME: string; +declare const SABLE_BUILD_FLAVOR: string; declare const APP_VERSION: string; declare const BUILD_HASH: string; declare const IS_RELEASE_TAG: boolean; diff --git a/src/serviceWorkerBootstrap.test.ts b/src/serviceWorkerBootstrap.test.ts index 6b2f42a565..aa3c866526 100644 --- a/src/serviceWorkerBootstrap.test.ts +++ b/src/serviceWorkerBootstrap.test.ts @@ -37,6 +37,7 @@ const { vi.mock('./app/utils/platform', () => ({ hasServiceWorker: mockHasServiceWorker, + isMobileOrTablet: vi.fn<() => boolean>().mockReturnValue(false), })); vi.mock('./sw-session', () => ({ diff --git a/vite.config.ts b/vite.config.ts index 0a87713185..18e33d244c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,10 @@ import { createRequire } from 'module'; import { sentryVitePlugin } from '@sentry/vite-plugin'; import buildConfig from './build.config'; +const appConfig = JSON.parse( + fs.readFileSync(path.resolve(__dirname, 'config.json'), 'utf8') +) as Record; + const packageJson = JSON.parse( fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf8') ) as { @@ -68,6 +72,8 @@ const isReleaseTag = (() => { } })(); +const baseProductName = typeof appConfig.productName === 'string' ? appConfig.productName : 'Sable'; + const copyFiles = { targets: [ { @@ -130,182 +136,191 @@ function serverMatrixSdkCryptoWasm() { }; } -export default defineConfig(({ command }) => ({ - clearScreen: false, - appType: 'spa', - publicDir: false, - base: buildConfig.base, - envPrefix: ['VITE_', 'TAURI_ENV_*'], - define: { - APP_VERSION: JSON.stringify(appVersion), - BUILD_HASH: JSON.stringify(buildHash ?? ''), - IS_RELEASE_TAG: JSON.stringify(isReleaseTag), - }, - resolve: { - alias: { - $hooks: path.resolve(__dirname, 'src/app/hooks'), - $plugins: path.resolve(__dirname, 'src/app/plugins'), - $components: path.resolve(__dirname, 'src/app/components'), - $features: path.resolve(__dirname, 'src/app/features'), - $state: path.resolve(__dirname, 'src/app/state'), - $styles: path.resolve(__dirname, 'src/app/styles'), - $utils: path.resolve(__dirname, 'src/app/utils'), - $pages: path.resolve(__dirname, 'src/app/pages'), - $generated: path.resolve(__dirname, 'src/app/generated'), - $types: path.resolve(__dirname, 'src/types'), - $public: path.resolve(__dirname, 'public'), - $client: path.resolve(__dirname, 'src/client'), - $unstable: path.resolve(__dirname, 'src/unstable'), - }, - }, - server: { - port: 8080, - strictPort: true, - host: tauriDevHost || true, - hmr: tauriDevHost - ? { - protocol: 'ws', - host: tauriDevHost, - port: 1421, - } - : undefined, - watch: { - ignored: ['**/src-tauri/**'], - }, - allowedHosts: command === 'serve' ? true : undefined, - fs: { - // Allow serving files from one level up to the project root - allow: ['..'], +export default defineConfig(({ command }) => { + const buildFlavor = process.env.SABLE_BUILD_FLAVOR || (command === 'serve' ? 'dev' : 'stable'); + const productName = buildFlavor !== 'stable' ? `${baseProductName} Nightly` : baseProductName; + + return { + clearScreen: false, + appType: 'spa', + publicDir: false, + base: buildConfig.base, + envPrefix: ['VITE_', 'TAURI_ENV_*'], + define: { + APP_VERSION: JSON.stringify(appVersion), + BUILD_HASH: JSON.stringify(buildHash ?? ''), + IS_RELEASE_TAG: JSON.stringify(isReleaseTag), + SABLE_PRODUCT_NAME: JSON.stringify(productName), + SABLE_BUILD_FLAVOR: JSON.stringify(buildFlavor), }, - }, - plugins: [ - serverMatrixSdkCryptoWasm(), - topLevelAwait({ - // The export name of top-level await promise for each chunk module - promiseExportName: '__tla', - // The function to generate import names of top-level await promise in each chunk module - promiseImportName: (i) => `__tla_${i}`, - }), - viteStaticCopy(copyFiles), - vanillaExtractPlugin({ identifiers: 'debug' }), - wasm() as PluginOption, - react(), - svgr(), - VitePWA({ - srcDir: 'src', - filename: 'sw.ts', - strategies: 'injectManifest', - injectRegister: false, - manifest: false, - injectManifest: { - // element-call is a self-contained embedded app; exclude its large assets - // from the SW precache manifest (they are not part of the Sable shell). - globIgnores: ['public/element-call/**', 'assets/lottie-*.js'], - // The app's own crypto WASM and main bundle exceed the 2 MiB default. - maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10 MiB + resolve: { + alias: { + $hooks: path.resolve(__dirname, 'src/app/hooks'), + $plugins: path.resolve(__dirname, 'src/app/plugins'), + $components: path.resolve(__dirname, 'src/app/components'), + $features: path.resolve(__dirname, 'src/app/features'), + $state: path.resolve(__dirname, 'src/app/state'), + $styles: path.resolve(__dirname, 'src/app/styles'), + $utils: path.resolve(__dirname, 'src/app/utils'), + $pages: path.resolve(__dirname, 'src/app/pages'), + $generated: path.resolve(__dirname, 'src/app/generated'), + $types: path.resolve(__dirname, 'src/types'), + $public: path.resolve(__dirname, 'public'), + $client: path.resolve(__dirname, 'src/client'), + $unstable: path.resolve(__dirname, 'src/unstable'), }, - devOptions: { - enabled: true, - type: 'module', + }, + server: { + port: 8080, + strictPort: true, + host: tauriDevHost || true, + hmr: tauriDevHost + ? { + protocol: 'ws', + host: tauriDevHost, + port: 1421, + } + : undefined, + watch: { + ignored: ['**/src-tauri/**'], }, - workbox: { - maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10 MB - globIgnores: [ - '**/matrix_sdk_crypto_wasm_bg-*.wasm', - '**/vision_wasm_internal-*.wasm', - '**/qcms_bg.wasm', - '**/openjpeg.wasm', - '**/jbig2.wasm', - ], + allowedHosts: command === 'serve' ? true : undefined, + fs: { + // Allow serving files from one level up to the project root + allow: ['..'], }, - }), - ...(!isTauriBuild - ? [ - cloudflare({ - config: { - compatibility_date: '2026-03-03', - assets: { - not_found_handling: 'single-page-application', + }, + plugins: [ + serverMatrixSdkCryptoWasm(), + topLevelAwait({ + // The export name of top-level await promise for each chunk module + promiseExportName: '__tla', + // The function to generate import names of top-level await promise in each chunk module + promiseImportName: (i) => `__tla_${i}`, + }), + viteStaticCopy(copyFiles), + vanillaExtractPlugin({ identifiers: 'debug' }), + wasm() as PluginOption, + react(), + svgr(), + VitePWA({ + srcDir: 'src', + filename: 'sw.ts', + strategies: 'injectManifest', + injectRegister: false, + manifest: false, + injectManifest: { + // element-call is a self-contained embedded app; exclude its large assets + // from the SW precache manifest (they are not part of the Sable shell). + globIgnores: ['public/element-call/**', 'assets/lottie-*.js'], + // The app's own crypto WASM and main bundle exceed the 2 MiB default. + maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10 MiB + }, + devOptions: { + enabled: true, + type: 'module', + }, + workbox: { + maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10 MB + globIgnores: [ + '**/matrix_sdk_crypto_wasm_bg-*.wasm', + '**/vision_wasm_internal-*.wasm', + '**/qcms_bg.wasm', + '**/openjpeg.wasm', + '**/jbig2.wasm', + ], + }, + }), + ...(!isTauriBuild + ? [ + cloudflare({ + config: { + compatibility_date: '2026-03-03', + assets: { + not_found_handling: 'single-page-application', + }, }, - }, - }), - compression({ - algorithms: [ - defineAlgorithm('brotliCompress', { - params: { [zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY }, - }), - ], - include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|txt|map)$/, - }), - ] - : []), - // Sentry source map upload — only active when credentials are provided at build time - ...(sentryUploadEnabled - ? [ - sentryVitePlugin({ - org: process.env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT, - authToken: process.env.SENTRY_AUTH_TOKEN, - sourcemaps: { - filesToDeleteAfterUpload: ['dist/**/*.map'], - }, - release: { - name: appVersion, - }, - // Annotate React components with data-sentry-* attributes at build - // time so Sentry can show component names in breadcrumbs, spans, - // and replay search instead of raw CSS selectors. - reactComponentAnnotation: { enabled: true }, - }), - ] - : []), - ], - optimizeDeps: { - // Include service worker entry so worker-only imports are discovered during startup. - entries: ['index.html', 'src/sw.ts'], - // Rebuild dep optimizer cache on each dev start to avoid stale API shapes. - force: true, - // Keep matrix-widget-api prebundled so matrix-js-sdk can import its named exports in dev. - // Force CJS interop for stability across optimizer cache rebuilds. - include: [ - 'matrix-widget-api', - 'workbox-precaching', - '@vanilla-extract/recipes/createRuntimeFn', + }), + compression({ + algorithms: [ + defineAlgorithm('brotliCompress', { + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY, + }, + }), + ], + include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|txt|map)$/, + }), + ] + : []), + // Sentry source map upload 窶・only active when credentials are provided at build time + ...(sentryUploadEnabled + ? [ + sentryVitePlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + sourcemaps: { + filesToDeleteAfterUpload: ['dist/**/*.map'], + }, + release: { + name: appVersion, + }, + // Annotate React components with data-sentry-* attributes at build + // time so Sentry can show component names in breadcrumbs, spans, + // and replay search instead of raw CSS selectors. + reactComponentAnnotation: { enabled: true }, + }), + ] + : []), ], - needsInterop: ['matrix-widget-api'], - esbuildOptions: { - define: { - global: 'globalThis', - }, - plugins: [ - // Enable esbuild polyfill plugins - NodeGlobalsPolyfillPlugin({ - process: false, - buffer: true, - }), + optimizeDeps: { + // Include service worker entry so worker-only imports are discovered during startup. + entries: ['index.html', 'src/sw.ts'], + // Rebuild dep optimizer cache on each dev start to avoid stale API shapes. + force: true, + // Keep matrix-widget-api prebundled so matrix-js-sdk can import its named exports in dev. + // Force CJS interop for stability across optimizer cache rebuilds. + include: [ + 'matrix-widget-api', + 'workbox-precaching', + '@vanilla-extract/recipes/createRuntimeFn', ], + needsInterop: ['matrix-widget-api'], + esbuildOptions: { + define: { + global: 'globalThis', + }, + plugins: [ + // Enable esbuild polyfill plugins + NodeGlobalsPolyfillPlugin({ + process: false, + buffer: true, + }), + ], + }, }, - }, - build: { - // es2022+ avoids esbuild 0.27.7 failing to downlevel destructuring when - // vite-plugin-top-level-await re-transpiles chunks (see vitejs/vite#22225). - target: 'es2022', - minify: isTauriBuild ? tauriBuildMinify : undefined, - sourcemap: isTauriBuild ? isTauriDebug || sentryUploadEnabled : true, - outDir: 'dist', - copyPublicDir: false, - rollupOptions: { - plugins: [inject({ Buffer: ['buffer', 'Buffer'] }) as PluginOption], - output: { - chunkFileNames: (chunk) => - chunk.moduleIds.some((id) => id.includes('@lottiefiles/dotlottie')) - ? 'assets/lottie-[hash].js' - : 'assets/[name]-[hash].js', - manualChunks: (id) => { - if (id.includes('@matrix-org') || id.includes('matrix-js-sdk')) return 'matrix'; - return undefined; + build: { + // es2022+ avoids esbuild 0.27.7 failing to downlevel destructuring when + // vite-plugin-top-level-await re-transpiles chunks (see vitejs/vite#22225). + target: 'es2022', + minify: isTauriBuild ? tauriBuildMinify : undefined, + sourcemap: isTauriBuild ? isTauriDebug || sentryUploadEnabled : true, + outDir: 'dist', + copyPublicDir: false, + rollupOptions: { + plugins: [inject({ Buffer: ['buffer', 'Buffer'] }) as PluginOption], + output: { + chunkFileNames: (chunk) => + chunk.moduleIds.some((id) => id.includes('@lottiefiles/dotlottie')) + ? 'assets/lottie-[hash].js' + : 'assets/[name]-[hash].js', + manualChunks: (id) => { + if (id.includes('@matrix-org') || id.includes('matrix-js-sdk')) return 'matrix'; + return undefined; + }, }, }, }, - }, -})); + }; +}); diff --git a/vitest.config.ts b/vitest.config.ts index 2149b5b109..6dbada7b99 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,9 +31,10 @@ export default defineConfig({ }, }, define: { + SABLE_PRODUCT_NAME: JSON.stringify('Sable'), + SABLE_BUILD_FLAVOR: JSON.stringify('stable'), APP_VERSION: JSON.stringify('test'), BUILD_HASH: JSON.stringify(''), - IS_RELEASE_TAG: JSON.stringify(false), }, test: { environment: 'jsdom',