mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Compare commits
79 Commits
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "FromChat",
|
||||
"user": "ubuntu",
|
||||
"install": "bash .cursor/scripts/cloud-setup.sh"
|
||||
}
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cloud Agent install script for the FromChat KMP client.
|
||||
#
|
||||
# Idempotent: safe to run repeatedly. Prepares everything needed to build the
|
||||
# Android app (`:app:android:assembleDebug`) and compile the shared module
|
||||
# (`:app:shared:compileAndroidMain`) on a headless Linux Cloud Agent VM:
|
||||
# 1. A JDK 17+ (uses the system JDK; installs OpenJDK 21 only if none is found).
|
||||
# 2. The Android SDK (command-line tools + required platforms/build-tools).
|
||||
# 3. Git-ignored local secrets the Gradle build reads at configuration time:
|
||||
# debug/release keystores, keys/keystore.properties, and a placeholder
|
||||
# google-services.json (Firebase config; the checked-in build applies the
|
||||
# google-services plugin). These are throwaway, non-secret dev values.
|
||||
# 4. local.properties pointing Gradle at the SDK.
|
||||
#
|
||||
# iOS targets (compileKotlinIosArm64 etc.) require macOS + Xcode and cannot be
|
||||
# built on this Linux VM; only the Android/shared-android surface is prepared.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
log() { printf '\n\033[1;34m[cloud-setup]\033[0m %s\n' "$*"; }
|
||||
|
||||
# --- 0. Base prerequisites ---------------------------------------------------
|
||||
# On the snapshot these already exist; guard for a bare base image.
|
||||
missing_pkgs=()
|
||||
for tool in curl unzip git; do
|
||||
command -v "$tool" >/dev/null 2>&1 || missing_pkgs+=("$tool")
|
||||
done
|
||||
command -v java >/dev/null 2>&1 || missing_pkgs+=("openjdk-21-jdk")
|
||||
if [ "${#missing_pkgs[@]}" -gt 0 ]; then
|
||||
log "Installing missing base packages: ${missing_pkgs[*]}"
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y --no-install-recommends "${missing_pkgs[@]}"
|
||||
fi
|
||||
|
||||
# --- 1. Java -----------------------------------------------------------------
|
||||
JAVA_BIN="$(command -v java)"
|
||||
JAVA_MAJOR="$(java -version 2>&1 | sed -n 's/.*version "\([0-9]*\).*/\1/p' | head -1)"
|
||||
log "Using Java: $JAVA_BIN (major ${JAVA_MAJOR:-unknown})"
|
||||
|
||||
# --- 2. Android SDK ----------------------------------------------------------
|
||||
export ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
|
||||
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
CMDLINE_TOOLS_ZIP_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
|
||||
|
||||
# Package versions must match the compileSdk/build-tools the Gradle build expects.
|
||||
SDK_PACKAGES=(
|
||||
"platform-tools"
|
||||
"platforms;android-37.0"
|
||||
"platforms;android-36"
|
||||
"build-tools;37.0.0"
|
||||
"build-tools;36.0.0"
|
||||
)
|
||||
|
||||
if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||
log "Installing Android command-line tools into $ANDROID_HOME"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
tmp_zip="$(mktemp --suffix=.zip)"
|
||||
curl -fsSL -o "$tmp_zip" "$CMDLINE_TOOLS_ZIP_URL"
|
||||
rm -rf "$ANDROID_HOME/cmdline-tools/latest" "$ANDROID_HOME/cmdline-tools/.tmp-extract"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools/.tmp-extract"
|
||||
unzip -q "$tmp_zip" -d "$ANDROID_HOME/cmdline-tools/.tmp-extract"
|
||||
mv "$ANDROID_HOME/cmdline-tools/.tmp-extract/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
rm -rf "$ANDROID_HOME/cmdline-tools/.tmp-extract" "$tmp_zip"
|
||||
fi
|
||||
|
||||
SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
|
||||
log "Accepting SDK licenses"
|
||||
yes | "$SDKMANAGER" --sdk_root="$ANDROID_HOME" --licenses >/dev/null 2>&1 || true
|
||||
log "Installing SDK packages: ${SDK_PACKAGES[*]}"
|
||||
"$SDKMANAGER" --sdk_root="$ANDROID_HOME" "${SDK_PACKAGES[@]}" >/dev/null
|
||||
|
||||
# --- 3. Local secrets the build reads (git-ignored, throwaway dev values) -----
|
||||
KEYS_DIR="$REPO_ROOT/app/android/keys"
|
||||
mkdir -p "$KEYS_DIR"
|
||||
|
||||
DEBUG_STORE_PASS="${DEBUG_STORE_PASS:-android}"
|
||||
DEBUG_KEY_PASS="${DEBUG_KEY_PASS:-android}"
|
||||
RELEASE_STORE_PASS="${RELEASE_STORE_PASS:-android}"
|
||||
RELEASE_KEY_PASS="${RELEASE_KEY_PASS:-android}"
|
||||
|
||||
# Debug keystore uses alias "debug"; release uses alias "key0" (see app/android/build.gradle.kts).
|
||||
if [ ! -f "$KEYS_DIR/debug.jks" ]; then
|
||||
log "Generating debug keystore"
|
||||
keytool -genkeypair -v -keystore "$KEYS_DIR/debug.jks" \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-alias debug -storepass "$DEBUG_STORE_PASS" -keypass "$DEBUG_KEY_PASS" \
|
||||
-dname "CN=Debug, O=FromChat, C=RU"
|
||||
fi
|
||||
|
||||
if [ ! -f "$KEYS_DIR/release.jks" ]; then
|
||||
log "Generating release keystore"
|
||||
keytool -genkeypair -v -keystore "$KEYS_DIR/release.jks" \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-alias key0 -storepass "$RELEASE_STORE_PASS" -keypass "$RELEASE_KEY_PASS" \
|
||||
-dname "CN=Release, O=FromChat, C=RU"
|
||||
fi
|
||||
|
||||
log "Writing keys/keystore.properties"
|
||||
cat > "$KEYS_DIR/keystore.properties" <<EOF
|
||||
releaseStorePassword=$RELEASE_STORE_PASS
|
||||
releaseKeyPassword=$RELEASE_KEY_PASS
|
||||
debugStorePassword=$DEBUG_STORE_PASS
|
||||
debugKeyPassword=$DEBUG_KEY_PASS
|
||||
EOF
|
||||
|
||||
# Placeholder Firebase config so the google-services plugin resolves. Contains no
|
||||
# real credentials; replace with a genuine google-services.json for push testing.
|
||||
GSJSON="$REPO_ROOT/app/android/google-services.json"
|
||||
if [ ! -f "$GSJSON" ]; then
|
||||
log "Writing placeholder google-services.json"
|
||||
cat > "$GSJSON" <<'EOF'
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "000000000000",
|
||||
"project_id": "fromchat-cloud-agent",
|
||||
"storage_bucket": "fromchat-cloud-agent.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000000000",
|
||||
"android_client_info": { "package_name": "ru.fromchat" }
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [ { "current_key": "AIzaSyDUMMYDUMMYDUMMYDUMMYDUMMYDUMMY000" } ],
|
||||
"services": { "appinvite_service": { "other_platform_oauth_client": [] } }
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:1111111111111111111111",
|
||||
"android_client_info": { "package_name": "ru.fromchat.beta" }
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [ { "current_key": "AIzaSyDUMMYDUMMYDUMMYDUMMYDUMMYDUMMY000" } ],
|
||||
"services": { "appinvite_service": { "other_platform_oauth_client": [] } }
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# --- 4. Point Gradle at the SDK ----------------------------------------------
|
||||
log "Writing local.properties"
|
||||
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > "$REPO_ROOT/local.properties"
|
||||
|
||||
# --- 5. Warm the Gradle cache and validate the Android build -----------------
|
||||
export JAVA_HOME="${JAVA_HOME:-$(dirname "$(dirname "$(readlink -f "$JAVA_BIN")")")}"
|
||||
log "Warming Gradle build (assembleDebug + shared androidMain)"
|
||||
./gradlew --no-daemon :app:shared:compileAndroidMain :app:android:assembleDebug
|
||||
|
||||
log "Setup complete. Debug APK:"
|
||||
ls -lh "$REPO_ROOT/app/android/build/outputs/apk/debug/" || true
|
||||
Submodule .cursor/skills/material-3-skill updated: 6ebd3ae7de...14385f2bf3
@@ -0,0 +1,52 @@
|
||||
name: Restore Gradle build directories
|
||||
description: Restore project .gradle/, build/ trees for faster CI Gradle jobs
|
||||
|
||||
inputs:
|
||||
scope:
|
||||
description: Unique id for this job (part of the cache key; use restore-keys to reuse other scopes)
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: Whether an exact cache key match was restored
|
||||
value: ${{ steps.cache.outputs.cache-hit == 'true' || steps.cache-android.outputs.cache-hit == 'true' }}
|
||||
cache-save-scope:
|
||||
description: Scope to pass to the save action (always this job's scope, not a restored fallback key)
|
||||
value: ${{ inputs.scope }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/cache/restore@v5
|
||||
if: inputs.scope == 'android'
|
||||
id: cache-android
|
||||
with:
|
||||
path: |
|
||||
.gradle
|
||||
build
|
||||
app/shared/build
|
||||
app/android/build
|
||||
utils/shared/build
|
||||
utils/android/build
|
||||
key: gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-${{ hashFiles('gradle/libs.versions.toml', 'settings.gradle.kts', 'gradle.properties', 'build.gradle.kts', 'app/desktop/build.gradle.kts', 'app/desktop/proguard-rules.pro', 'app/shared/build.gradle.kts', 'app/android/build.gradle.kts', 'utils/shared/build.gradle.kts', 'utils/android/build.gradle.kts') }}
|
||||
restore-keys: |
|
||||
gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-
|
||||
gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-build-jar-
|
||||
gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-
|
||||
- uses: actions/cache/restore@v5
|
||||
if: inputs.scope != 'android'
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
.gradle
|
||||
build
|
||||
app/desktop/build
|
||||
app/shared/build
|
||||
app/android/build
|
||||
utils/shared/build
|
||||
utils/android/build
|
||||
key: gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-${{ hashFiles('gradle/libs.versions.toml', 'settings.gradle.kts', 'gradle.properties', 'build.gradle.kts', 'app/desktop/build.gradle.kts', 'app/desktop/proguard-rules.pro', 'app/shared/build.gradle.kts', 'app/android/build.gradle.kts', 'utils/shared/build.gradle.kts', 'utils/android/build.gradle.kts') }}
|
||||
restore-keys: |
|
||||
gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-
|
||||
gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-build-jar-
|
||||
gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Restore Windows Rust build cache
|
||||
description: Restore Cargo registry, git checkouts, and the windows-setup target directory
|
||||
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: Whether an exact cache key match was restored
|
||||
value: ${{ steps.cache.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/cache/restore@v5
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
app/desktop/windows-setup/target
|
||||
key: windows-rust-${{ runner.os }}-v3-${{ hashFiles('app/desktop/windows-setup/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
windows-rust-${{ runner.os }}-v3-
|
||||
windows-rust-${{ runner.os }}-v2-
|
||||
windows-rust-tools-v1-
|
||||
cargo-registry-${{ runner.os }}-
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Restore git mtimes
|
||||
description: Reset tracked file timestamps from git so Gradle incremental builds can be UP-TO-DATE after checkout
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Restore mtimes
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v git-restore-mtime >/dev/null 2>&1; then
|
||||
git-restore-mtime
|
||||
exit 0
|
||||
fi
|
||||
script="${RUNNER_TEMP}/git-restore-mtime"
|
||||
curl -fsSL https://raw.githubusercontent.com/MestreLion/git-tools/b5822d297b3ca4a3b6574e78327a464790b14937/git-restore-mtime -o "$script"
|
||||
python3 "$script"
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Save Gradle build directories
|
||||
description: Save project .gradle/, build/ trees after a Gradle build
|
||||
|
||||
inputs:
|
||||
scope:
|
||||
description: Same scope passed to cache-gradle-build (save always uses this job's key, not a restored fallback key)
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Save Android Gradle build cache
|
||||
if: inputs.scope == 'android'
|
||||
uses: actions/cache/save@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-${{ hashFiles('gradle/libs.versions.toml', 'settings.gradle.kts', 'gradle.properties', 'build.gradle.kts', 'app/desktop/build.gradle.kts', 'app/desktop/proguard-rules.pro', 'app/shared/build.gradle.kts', 'app/android/build.gradle.kts', 'utils/shared/build.gradle.kts', 'utils/android/build.gradle.kts') }}
|
||||
path: |
|
||||
.gradle
|
||||
build
|
||||
app/shared/build
|
||||
app/android/build
|
||||
utils/shared/build
|
||||
utils/android/build
|
||||
- name: Save Gradle build cache
|
||||
if: inputs.scope != 'android'
|
||||
uses: actions/cache/save@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-${{ hashFiles('gradle/libs.versions.toml', 'settings.gradle.kts', 'gradle.properties', 'build.gradle.kts', 'app/desktop/build.gradle.kts', 'app/desktop/proguard-rules.pro', 'app/shared/build.gradle.kts', 'app/android/build.gradle.kts', 'utils/shared/build.gradle.kts', 'utils/android/build.gradle.kts') }}
|
||||
path: |
|
||||
.gradle
|
||||
build
|
||||
app/desktop/build
|
||||
app/shared/build
|
||||
app/android/build
|
||||
utils/shared/build
|
||||
utils/android/build
|
||||
@@ -0,0 +1,14 @@
|
||||
name: Save Windows Rust build cache
|
||||
description: Save Cargo registry, git checkouts, and the windows-setup target directory
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/cache/save@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: windows-rust-${{ runner.os }}-v3-${{ hashFiles('app/desktop/windows-setup/Cargo.lock') }}
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
app/desktop/windows-setup/target
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Set release version
|
||||
description: Writes versionName/versionCode into build.gradle.kts for CI
|
||||
|
||||
inputs:
|
||||
version:
|
||||
description: Semantic version (e.g. 1.2.3)
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
VERSION_CODE="$(printf '%s' "$VERSION" | sed 's/[^0-9]//g')"
|
||||
if [[ -z "$VERSION_CODE" ]]; then
|
||||
VERSION_CODE=1
|
||||
fi
|
||||
GRADLE_FILE="build.gradle.kts"
|
||||
CURRENT_NAME="$(sed -n 's/.*extra\["versionName"\] = "\([^"]*\)".*/\1/p' "$GRADLE_FILE" | head -n 1)"
|
||||
CURRENT_CODE="$(sed -n 's/.*extra\["versionCode"\] = \([0-9]*\).*/\1/p' "$GRADLE_FILE" | head -n 1)"
|
||||
if [[ "$CURRENT_NAME" == "$VERSION" && "$CURRENT_CODE" == "$VERSION_CODE" ]]; then
|
||||
echo "versionName=$VERSION versionCode=$VERSION_CODE (unchanged)"
|
||||
exit 0
|
||||
fi
|
||||
sed -i.bak "s/extra\\[\"versionName\"\\] = \"[^\"]*\"/extra[\"versionName\"] = \"$VERSION\"/" "$GRADLE_FILE"
|
||||
sed -i.bak "s/extra\\[\"versionCode\"\\] = [0-9]*/extra[\"versionCode\"] = $VERSION_CODE/" "$GRADLE_FILE"
|
||||
rm -f "$GRADLE_FILE.bak"
|
||||
echo "versionName=$VERSION versionCode=$VERSION_CODE"
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Setup Java and Gradle
|
||||
description: Temurin JDK 17 + Gradle wrapper with dependency and build cache
|
||||
|
||||
inputs:
|
||||
java-architecture:
|
||||
description: Optional JDK architecture (x64). Omit for host default.
|
||||
required: false
|
||||
default: ""
|
||||
cache-read-only:
|
||||
description: Restore Gradle User Home only; do not save (avoids parallel cache write races).
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
architecture: ${{ inputs.java-architecture }}
|
||||
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
gradle-version: wrapper
|
||||
cache-read-only: ${{ inputs.cache-read-only == 'true' }}
|
||||
cache-overwrite-existing: true
|
||||
add-job-summary: on-failure
|
||||
@@ -1,11 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gradle" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Windows x64 installer
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "App version (e.g. 1.1.4)"
|
||||
required: false
|
||||
default: "1.1.4"
|
||||
push:
|
||||
tags:
|
||||
- "win-x64-*"
|
||||
|
||||
concurrency:
|
||||
group: desktop-windows-x64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GRADLE_OPTS: >-
|
||||
-Dorg.gradle.jvmargs=-Xmx6g
|
||||
-Dkotlin.daemon.jvm.options=-Xmx6g
|
||||
-Dorg.gradle.parallel=true
|
||||
|
||||
jobs:
|
||||
windows-x64:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/setup-java-gradle
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
app/desktop/windows-setup/target
|
||||
key: cargo-windows-${{ runner.os }}-${{ hashFiles('app/desktop/windows-setup/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-windows-${{ runner.os }}-
|
||||
- id: ver
|
||||
name: Resolve version
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
V="${{ inputs.version }}"
|
||||
else
|
||||
V="${GITHUB_REF_NAME#win-x64-}"
|
||||
fi
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
- uses: ./.github/actions/set-version
|
||||
with:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
- name: Package Windows x64 installer
|
||||
shell: bash
|
||||
run: |
|
||||
export FROMCHAT_PACKAGING_JDK="$JAVA_HOME"
|
||||
./gradlew :app:desktop:packageBetaWindows -PbetaDesktop -PdesktopArch=x64 --no-daemon --console=plain
|
||||
- uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: windows-x64-installer
|
||||
path: app/desktop/build/distributions/release/FromChat-Setup-*-beta2.exe
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,558 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g. 1.1.4). Overrides tag when set."
|
||||
required: true
|
||||
type: string
|
||||
skip_publish:
|
||||
description: "Skip GitHub Release upload"
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
default: "false"
|
||||
force_update_release:
|
||||
description: "Overwrite existing GitHub Release for this version instead of failing"
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
default: "false"
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GRADLE_OPTS: >-
|
||||
-Dorg.gradle.jvmargs=-Xmx6g
|
||||
-Dkotlin.daemon.jvm.options=-Xmx6g
|
||||
-Dorg.gradle.parallel=true
|
||||
|
||||
jobs:
|
||||
resolve-version:
|
||||
name: Resolve version
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
tag: ${{ steps.ver.outputs.tag }}
|
||||
prerelease: ${{ steps.ver.outputs.prerelease }}
|
||||
release_label: ${{ steps.ver.outputs.release_label }}
|
||||
force_update_release: ${{ steps.ver.outputs.force_update_release }}
|
||||
steps:
|
||||
- id: ver
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
V="${{ inputs.version }}"
|
||||
else
|
||||
V="${GITHUB_REF_NAME#v}"
|
||||
fi
|
||||
if [[ ! "$V" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-].*)?$ ]]; then
|
||||
echo "Invalid version: $V" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$V" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
echo "Cannot derive release label from version: $V" >&2
|
||||
exit 1
|
||||
fi
|
||||
MAJOR="${BASH_REMATCH[1]}"
|
||||
MINOR="${BASH_REMATCH[2]}"
|
||||
PATCH="${BASH_REMATCH[3]}"
|
||||
if [[ "$PATCH" == "0" ]]; then
|
||||
RELEASE_LABEL="${MAJOR}.${MINOR}"
|
||||
else
|
||||
RELEASE_LABEL="${MAJOR}.${MINOR}.${PATCH}"
|
||||
fi
|
||||
TAG="v$V"
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "release_label=$RELEASE_LABEL" >> "$GITHUB_OUTPUT"
|
||||
case "$V" in
|
||||
*-beta*) echo "prerelease=true" >> "$GITHUB_OUTPUT" ;;
|
||||
*) echo "prerelease=false" >> "$GITHUB_OUTPUT" ;;
|
||||
esac
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.force_update_release }}" = "true" ]; then
|
||||
echo "Release $TAG exists; force_update_release enabled — publish will overwrite it."
|
||||
echo "force_update_release=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Release $TAG already exists. Delete it first, or run workflow_dispatch with force_update_release=true." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "force_update_release=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
desktop-proguard:
|
||||
name: Build JAR
|
||||
needs: resolve-version
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
FROMCHAT_PREBUILT_PROGUARD: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/restore-git-mtimes
|
||||
- uses: ./.github/actions/set-version
|
||||
with:
|
||||
version: ${{ needs.resolve-version.outputs.version }}
|
||||
- uses: ./.github/actions/setup-java-gradle
|
||||
- uses: ./.github/actions/cache-gradle-build
|
||||
id: gradle-build-cache
|
||||
with:
|
||||
scope: build-jar
|
||||
- name: Compile and ProGuard release desktop jars
|
||||
run: ./gradlew :app:desktop:exportReleaseProguardForCi --no-daemon --console=plain
|
||||
- uses: ./.github/actions/save-gradle-build-cache
|
||||
if: always()
|
||||
with:
|
||||
scope: ${{ steps.gradle-build-cache.outputs.cache-save-scope }}
|
||||
- uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: desktop-proguard
|
||||
path: app/desktop/build/ci/proguard/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
windows-common:
|
||||
name: Windows installer
|
||||
needs: resolve-version
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/restore-git-mtimes
|
||||
- uses: ./.github/actions/set-version
|
||||
with:
|
||||
version: ${{ needs.resolve-version.outputs.version }}
|
||||
- uses: ./.github/actions/setup-java-gradle
|
||||
- uses: ./.github/actions/cache-gradle-build
|
||||
id: gradle-build-cache
|
||||
with:
|
||||
scope: windows-installer
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
- uses: ./.github/actions/cache-windows-rust-tools
|
||||
id: rust-tools-cache
|
||||
- name: Verify Windows Rust binaries
|
||||
id: rust-binaries
|
||||
shell: bash
|
||||
run: |
|
||||
ready=true
|
||||
for exe in \
|
||||
FromChat-Installer.exe \
|
||||
FromChat-Installer-Helper.exe \
|
||||
fromchat-portable-launcher.exe \
|
||||
fromchat-pack.exe \
|
||||
fromchat-icon-patch.exe
|
||||
do
|
||||
if [[ ! -f "app/desktop/windows-setup/target/release/$exe" ]]; then
|
||||
echo "Missing $exe"
|
||||
ready=false
|
||||
fi
|
||||
done
|
||||
echo "ready=$ready" >> "$GITHUB_OUTPUT"
|
||||
- name: Build Windows installer Rust tooling
|
||||
if: steps.rust-binaries.outputs.ready != 'true'
|
||||
shell: bash
|
||||
run: ./gradlew :app:desktop:buildWindowsSetupRust --no-daemon --console=plain
|
||||
- uses: ./.github/actions/save-windows-rust-cache
|
||||
if: always()
|
||||
- uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: windows-rust-tools
|
||||
path: app/desktop/windows-setup/target/release/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: ./.github/actions/save-gradle-build-cache
|
||||
if: always()
|
||||
with:
|
||||
scope: ${{ steps.gradle-build-cache.outputs.cache-save-scope }}
|
||||
|
||||
windows-package:
|
||||
name: Windows (${{ matrix.label }})
|
||||
needs: [resolve-version, desktop-proguard, windows-common]
|
||||
env:
|
||||
FROMCHAT_PREBUILT_PROGUARD: "1"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
label: x64
|
||||
runner: windows-latest
|
||||
- arch: arm64
|
||||
label: ARM
|
||||
runner: windows-11-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/restore-git-mtimes
|
||||
- uses: ./.github/actions/set-version
|
||||
with:
|
||||
version: ${{ needs.resolve-version.outputs.version }}
|
||||
- name: Ensure ARM64 packaging JDK
|
||||
if: matrix.arch == 'arm64'
|
||||
shell: cmd
|
||||
run: call scripts\ensure-windows-arm64-jdk.cmd
|
||||
- uses: ./.github/actions/setup-java-gradle
|
||||
if: matrix.arch == 'x64'
|
||||
with:
|
||||
cache-read-only: "true"
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
if: matrix.arch == 'arm64'
|
||||
with:
|
||||
gradle-version: wrapper
|
||||
cache-read-only: true
|
||||
add-job-summary: on-failure
|
||||
- uses: ./.github/actions/cache-gradle-build
|
||||
id: gradle-build-cache
|
||||
with:
|
||||
scope: windows-package-${{ matrix.arch }}
|
||||
- uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: desktop-proguard
|
||||
path: app/desktop/build/prebuilt/main-release/proguard
|
||||
- name: Restore Windows Rust installer tools
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: windows-rust-tools
|
||||
path: app/desktop/windows-setup/target/release
|
||||
- name: Package Windows ${{ matrix.arch }} installer
|
||||
shell: bash
|
||||
env:
|
||||
FROMCHAT_SKIP_RUST_BUILD: "1"
|
||||
run: |
|
||||
export FROMCHAT_PACKAGING_JDK="$JAVA_HOME"
|
||||
args=(
|
||||
:app:desktop:packSetupOnly
|
||||
"-PdesktopArch=${{ matrix.arch }}"
|
||||
--no-daemon
|
||||
--console=plain
|
||||
)
|
||||
if [ "${{ matrix.arch }}" = "arm64" ]; then
|
||||
args+=("-PwindowsArm64")
|
||||
fi
|
||||
./gradlew "${args[@]}"
|
||||
- uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: windows-${{ matrix.arch }}
|
||||
path: app/desktop/build/distributions/release/*-windows-${{ matrix.arch }}.exe
|
||||
if-no-files-found: error
|
||||
- uses: ./.github/actions/save-gradle-build-cache
|
||||
if: always()
|
||||
with:
|
||||
scope: ${{ steps.gradle-build-cache.outputs.cache-save-scope }}
|
||||
|
||||
macos:
|
||||
name: macOS (${{ matrix.label }})
|
||||
needs: [resolve-version, desktop-proguard]
|
||||
env:
|
||||
FROMCHAT_PREBUILT_PROGUARD: "1"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
label: Apple Silicon
|
||||
- arch: x64
|
||||
label: Intel
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/restore-git-mtimes
|
||||
- uses: ./.github/actions/set-version
|
||||
with:
|
||||
version: ${{ needs.resolve-version.outputs.version }}
|
||||
- uses: ./.github/actions/setup-java-gradle
|
||||
if: matrix.arch == 'x64'
|
||||
with:
|
||||
java-architecture: x64
|
||||
cache-read-only: "true"
|
||||
- uses: ./.github/actions/setup-java-gradle
|
||||
if: matrix.arch == 'arm64'
|
||||
with:
|
||||
cache-read-only: "true"
|
||||
- uses: ./.github/actions/cache-gradle-build
|
||||
id: gradle-build-cache
|
||||
with:
|
||||
scope: macos-${{ matrix.arch }}
|
||||
- uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: desktop-proguard
|
||||
path: app/desktop/build/prebuilt/main-release/proguard
|
||||
- name: Cache DMG background tooling
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
app/desktop/dmg-background/node_modules
|
||||
~/.cache/ms-playwright
|
||||
key: dmg-tools-${{ runner.arch }}-${{ hashFiles('app/desktop/dmg-background/package-lock.json') }}
|
||||
restore-keys: dmg-tools-${{ runner.arch }}-
|
||||
- name: Install macOS packaging tools
|
||||
run: |
|
||||
if ! command -v create-dmg >/dev/null 2>&1; then
|
||||
brew install create-dmg
|
||||
fi
|
||||
DMG_DIR="app/desktop/dmg-background"
|
||||
if [[ ! -f "$DMG_DIR/node_modules/playwright/package.json" ]]; then
|
||||
npm --prefix "$DMG_DIR" ci --no-fund --no-audit
|
||||
fi
|
||||
npm --prefix "$DMG_DIR" exec playwright install chromium
|
||||
- name: Package macOS ${{ matrix.arch }}
|
||||
run: |
|
||||
export FROMCHAT_PACKAGING_JDK="$JAVA_HOME"
|
||||
./gradlew :app:desktop:packageReleaseMac -PdesktopArch=${{ matrix.arch }} --no-daemon --console=plain
|
||||
- uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: macos-${{ matrix.arch }}
|
||||
path: app/desktop/build/distributions/release/*-macOS-${{ matrix.arch }}.dmg
|
||||
if-no-files-found: error
|
||||
- uses: ./.github/actions/save-gradle-build-cache
|
||||
if: always()
|
||||
with:
|
||||
scope: ${{ steps.gradle-build-cache.outputs.cache-save-scope }}
|
||||
|
||||
linux:
|
||||
name: Linux (${{ matrix.label }})
|
||||
needs: [resolve-version, desktop-proguard]
|
||||
env:
|
||||
FROMCHAT_PREBUILT_PROGUARD: "1"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
label: x64
|
||||
runner: ubuntu-latest
|
||||
appimage: appimagetool-x86_64.AppImage
|
||||
- arch: arm64
|
||||
label: ARM
|
||||
runner: ubuntu-24.04-arm
|
||||
appimage: appimagetool-aarch64.AppImage
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/restore-git-mtimes
|
||||
- uses: ./.github/actions/set-version
|
||||
with:
|
||||
version: ${{ needs.resolve-version.outputs.version }}
|
||||
- uses: ./.github/actions/setup-java-gradle
|
||||
with:
|
||||
cache-read-only: "true"
|
||||
- uses: ./.github/actions/cache-gradle-build
|
||||
id: gradle-build-cache
|
||||
with:
|
||||
scope: linux-${{ matrix.arch }}
|
||||
- uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: desktop-proguard
|
||||
path: app/desktop/build/prebuilt/main-release/proguard
|
||||
- name: Install Linux packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
fakeroot rpm binutils libfuse2 wget
|
||||
CACHE_DIR="${RUNNER_TOOL_CACHE}/appimagetool"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
TOOL="$CACHE_DIR/appimagetool-${{ matrix.arch }}.AppImage"
|
||||
if [[ ! -f "$TOOL" ]]; then
|
||||
wget -q "https://github.com/AppImage/appimagetool/releases/download/continuous/${{ matrix.appimage }}" -O "$TOOL"
|
||||
chmod +x "$TOOL"
|
||||
fi
|
||||
echo "APPIMAGETOOL=$TOOL" >> "$GITHUB_ENV"
|
||||
- name: Package Linux ${{ matrix.arch }}
|
||||
run: |
|
||||
export FROMCHAT_PACKAGING_JDK="$JAVA_HOME"
|
||||
./gradlew :app:desktop:packageReleaseLinux -PdesktopArch=${{ matrix.arch }} --no-daemon --console=plain
|
||||
- uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: linux-${{ matrix.arch }}
|
||||
path: |
|
||||
app/desktop/build/distributions/release/*-linux-${{ matrix.arch }}.deb
|
||||
app/desktop/build/distributions/release/*-linux-${{ matrix.arch }}.rpm
|
||||
app/desktop/build/distributions/release/*-linux-${{ matrix.arch }}.AppImage
|
||||
if-no-files-found: error
|
||||
- uses: ./.github/actions/save-gradle-build-cache
|
||||
if: always()
|
||||
with:
|
||||
scope: ${{ steps.gradle-build-cache.outputs.cache-save-scope }}
|
||||
|
||||
android:
|
||||
name: Android
|
||||
needs: resolve-version
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/restore-git-mtimes
|
||||
- uses: ./.github/actions/set-version
|
||||
with:
|
||||
version: ${{ needs.resolve-version.outputs.version }}
|
||||
- uses: ./.github/actions/setup-java-gradle
|
||||
with:
|
||||
cache-read-only: "true"
|
||||
- uses: android-actions/setup-android@v4
|
||||
- uses: ./.github/actions/cache-gradle-build
|
||||
id: gradle-build-cache
|
||||
with:
|
||||
scope: android
|
||||
- name: Prepare Android SDK cache dirs
|
||||
run: mkdir -p ~/.android/build-cache ~/.android/cache
|
||||
- name: Cache Android SDK extras
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.android/build-cache
|
||||
~/.android/cache
|
||||
key: android-sdk-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', 'app/android/build.gradle.kts') }}
|
||||
restore-keys: android-sdk-${{ runner.os }}-
|
||||
- name: Prepare Android signing
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
GOOGLE_SERVICES_JSON: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON }}
|
||||
RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_STORE_PASSWORD }}
|
||||
RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
|
||||
run: |
|
||||
if [[ -z "$KEYSTORE_BASE64" || -z "$GOOGLE_SERVICES_JSON" ]]; then
|
||||
echo "Missing ANDROID_KEYSTORE_BASE64 or ANDROID_GOOGLE_SERVICES_JSON secrets" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p app/android/keys
|
||||
printf '%s' "$KEYSTORE_BASE64" | base64 -d > app/android/keys/release.jks
|
||||
keytool -genkeypair -noprompt \
|
||||
-alias key0 -keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-dname "CN=FromChat Debug" \
|
||||
-keystore app/android/keys/debug.jks \
|
||||
-storepass android -keypass android
|
||||
{
|
||||
echo "releaseStorePassword=${RELEASE_STORE_PASSWORD}"
|
||||
echo "releaseKeyPassword=${RELEASE_KEY_PASSWORD}"
|
||||
echo "debugStorePassword=android"
|
||||
echo "debugKeyPassword=android"
|
||||
} > app/android/keys/keystore.properties
|
||||
printf '%s' "$GOOGLE_SERVICES_JSON" | base64 -d > app/android/google-services.json
|
||||
- name: Build universal Android APK
|
||||
run: ./gradlew :app:android:assembleRelease --no-daemon --console=plain
|
||||
- uses: ./.github/actions/save-gradle-build-cache
|
||||
if: always()
|
||||
with:
|
||||
scope: ${{ steps.gradle-build-cache.outputs.cache-save-scope }}
|
||||
- name: Stage APK
|
||||
run: |
|
||||
mkdir -p release-staging
|
||||
APK="$(find app/android/build/outputs/apk/release -name '*release*.apk' | head -n 1)"
|
||||
cp "$APK" "release-staging/FromChat-${{ needs.resolve-version.outputs.release_label }}.apk"
|
||||
- uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: android
|
||||
path: release-staging/*.apk
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.skip_publish != 'true' }}
|
||||
needs:
|
||||
- resolve-version
|
||||
- windows-package
|
||||
- macos
|
||||
- linux
|
||||
- android
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: |
|
||||
RELEASE_NOTES.md
|
||||
sparse-checkout-cone-mode: false
|
||||
- uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: "{windows,macos,linux}-*"
|
||||
merge-multiple: true
|
||||
path: artifacts
|
||||
- uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: android
|
||||
path: artifacts
|
||||
- name: Collect release assets
|
||||
env:
|
||||
LABEL: ${{ needs.resolve-version.outputs.release_label }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! -d artifacts ]]; then
|
||||
echo "No artifacts were downloaded" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p release-assets
|
||||
|
||||
pick_one() {
|
||||
local pattern="$1"
|
||||
local dest="$2"
|
||||
local match
|
||||
match=$(find artifacts -type f -name "$pattern" | head -n 1)
|
||||
if [[ -z "$match" ]]; then
|
||||
echo "Missing artifact matching $pattern" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$match" "release-assets/$dest"
|
||||
}
|
||||
|
||||
pick_one "*-windows-x64.exe" "FromChat-${LABEL}-x64.exe"
|
||||
pick_one "*-windows-arm64.exe" "FromChat-${LABEL}-arm.exe"
|
||||
pick_one "*-macOS-arm64.dmg" "FromChat-${LABEL}-apple.dmg"
|
||||
pick_one "*-macOS-x64.dmg" "FromChat-${LABEL}-intel.dmg"
|
||||
pick_one "*-linux-x64.deb" "FromChat-${LABEL}-x64.deb"
|
||||
pick_one "*-linux-arm64.deb" "FromChat-${LABEL}-arm.deb"
|
||||
pick_one "*-linux-x64.rpm" "FromChat-${LABEL}-x64.rpm"
|
||||
pick_one "*-linux-arm64.rpm" "FromChat-${LABEL}-arm.rpm"
|
||||
pick_one "*-linux-x64.AppImage" "FromChat-${LABEL}-x64.AppImage"
|
||||
pick_one "*-linux-arm64.AppImage" "FromChat-${LABEL}-arm.AppImage"
|
||||
pick_one "*.apk" "FromChat-${LABEL}.apk"
|
||||
|
||||
if find release-assets -type f -name '*.zip' | grep -q .; then
|
||||
echo "Release assets must not be zip archives" >&2
|
||||
exit 1
|
||||
fi
|
||||
ls -la release-assets
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.resolve-version.outputs.tag }}
|
||||
name: ${{ needs.resolve-version.outputs.tag }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
draft: false
|
||||
prerelease: ${{ needs.resolve-version.outputs.prerelease == 'true' }}
|
||||
generate_release_notes: false
|
||||
overwrite_existing: ${{ needs.resolve-version.outputs.force_update_release == 'true' }}
|
||||
files: |
|
||||
release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}.apk
|
||||
release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-x64.exe
|
||||
release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-arm.exe
|
||||
release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-apple.dmg
|
||||
release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-intel.dmg
|
||||
release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-x64.deb
|
||||
release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-arm.deb
|
||||
release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-x64.rpm
|
||||
release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-arm.rpm
|
||||
release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-x64.AppImage
|
||||
release-assets/FromChat-${{ needs.resolve-version.outputs.release_label }}-arm.AppImage
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+6
-1
@@ -31,4 +31,9 @@ google-services.json
|
||||
*.log
|
||||
|
||||
premortem-transcript-*.md
|
||||
premortem-report-*.html
|
||||
premortem-report-*.html
|
||||
|
||||
target/
|
||||
|
||||
node_modules
|
||||
preview-dist
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="shared-jvm">
|
||||
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
|
||||
<root id="archive" name="shared-jvm.jar">
|
||||
<element id="module-output" name="FromChat.utils.shared.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
<bytecodeTargetLevel target="17" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+1
-1
@@ -6,12 +6,12 @@
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
<option value="$PROJECT_DIR$/app/android" />
|
||||
<option value="$PROJECT_DIR$/app/desktop" />
|
||||
<option value="$PROJECT_DIR$/app/shared" />
|
||||
<option value="$PROJECT_DIR$/utils" />
|
||||
<option value="$PROJECT_DIR$/utils/android" />
|
||||
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="externalSystemId" value="Gradle" />
|
||||
<option name="version" value="2.4.20" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+7
-1
@@ -1,6 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CidrRootsConfiguration">
|
||||
<excludeRoots>
|
||||
<file path="$PROJECT_DIR$/app/android/keys" />
|
||||
</excludeRoots>
|
||||
</component>
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
||||
+41
-18
@@ -2,14 +2,14 @@ Read in other languages: [Русский](./README.md)
|
||||
|
||||
# FromChat
|
||||
|
||||
FromChat is a 100% free and open-source messenger. This repository is the cross-platform client (Android + iOS; iOS is not ready yet).
|
||||
FromChat is a 100% free and open-source messenger. This repository is the cross-platform client (Android, Desktop via Compose Multiplatform, and iOS).
|
||||
|
||||
[📥 Download](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram Channel](https://t.me/fromchat_ch) • [🖥️ Server](https://github.com/fromchat-messenger/backend)
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Voice and video calls** — LiveKit
|
||||
- **Screen sharing** during calls
|
||||
- **Screen sharing** during calls (Android)
|
||||
- **Public chat** — server-wide community
|
||||
- **Direct messages** — legal encryption scheme; server-side E2EE planned
|
||||
- **Device management** — active sessions
|
||||
@@ -18,15 +18,35 @@ FromChat is a 100% free and open-source messenger. This repository is the cross-
|
||||
|
||||
## 📊 Client Comparison
|
||||
|
||||
⚠️ **iOS is temporarily not supported** (Apple constraints and development cost). It will ship later.
|
||||
| Feature | Android | Desktop | Web | iOS |
|
||||
|-----------------------------|---------|--------------------------|-----|-------------------|
|
||||
| **Messaging and profiles** | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Voice/video calls** | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Screen sharing** | ✅ | ❌ | ✅ | ❌ |
|
||||
| **Push when backgrounded** | ✅ (FCM) | ✅ (persistent WS + tray) | ✅ | ❌ (WS while open) |
|
||||
| **Message reactions** | ❌ | ❌ | ✅ | ❌ |
|
||||
| **Rich attachment support** | ✅ | ✅ | ❌ | ✅ |
|
||||
|
||||
| Feature | Android | Web | iOS |
|
||||
| --- | --- | --- | --- |
|
||||
| **Messaging and profiles** | ✅ | ✅ | ❌ |
|
||||
| **Voice/video calls** | ✅ | ✅ | ❌ |
|
||||
| **Screen sharing** | ✅ | ✅ | ❌ |
|
||||
| **Message reactions** | ❌ | ✅ | ❌ |
|
||||
| **Rich attachment support** | ✅ | ❌ | ❌ |
|
||||
Desktop replaces the former Electron client in the Web repo. Run with `./gradlew :app:desktop:run`.
|
||||
|
||||
### Desktop packaging
|
||||
|
||||
Build release packages on the matching OS (or via CI):
|
||||
|
||||
| Task | Host | Artifacts |
|
||||
|------------------------------------------------|------------|-------------------------------------------------------------------------------------|
|
||||
| `./gradlew :app:desktop:packageReleaseMac` | macOS | `FromChat-<ver>-macOS.dmg` |
|
||||
| `./gradlew :app:desktop:packageReleaseLinux` | Linux | `.deb`, `.rpm`, `.AppImage` (needs `appimagetool`) |
|
||||
| `./gradlew :app:desktop:packageReleaseWindows` | Windows | `FromChat-Setup-<ver>.exe`, `FromChat-Portable-<ver>.exe` (Rust toolchain required) |
|
||||
| `./gradlew :app:desktop:packageReleaseDesktop` | current OS | delegates to the task above |
|
||||
|
||||
Outputs land under `app/desktop/build/distributions/release/`.
|
||||
|
||||
**CI:** `.github/workflows/desktop-release.yml` runs on `v*` tags and `workflow_dispatch` (required `version` input), then uploads assets to a GitHub Release.
|
||||
|
||||
**Windows portable:** extracts into a folder where only `FromChat Portable.exe` is visible; other runtime files are Hidden. Data is stored in a Hidden `fromchat-data/` next to that EXE (`-Dfromchat.portable=true`).
|
||||
|
||||
**Installed data:** Windows `%LOCALAPPDATA%\FromChat`, macOS `~/Library/Application Support/FromChat`, Linux `~/.local/share/FromChat` (migrates from legacy `~/.fromchat/cache` when present).
|
||||
|
||||
---
|
||||
|
||||
@@ -69,22 +89,25 @@ FromChat is a 100% free and open-source messenger. This repository is the cross-
|
||||
|
||||
mkdir -p app/android/keys
|
||||
|
||||
# Quote passwords: characters like & * ? ! break unquoted shell args.
|
||||
keytool -genkey -v -keystore app/android/keys/debug.jks \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-alias key0 -storepass $DEBUG_STORE_PASS -keypass $DEBUG_KEY_PASS \
|
||||
-alias key0 -storepass "$DEBUG_STORE_PASS" -keypass "$DEBUG_KEY_PASS" \
|
||||
-dname "CN=Debug, O=FromChat, C=RU"
|
||||
|
||||
keytool -genkey -v -keystore app/android/keys/release.jks \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-alias key0 -storepass $RELEASE_STORE_PASS -keypass $RELEASE_KEY_PASS \
|
||||
-alias key0 -storepass "$RELEASE_STORE_PASS" -keypass "$RELEASE_KEY_PASS" \
|
||||
-dname "CN=Release, O=FromChat, C=RU"
|
||||
|
||||
cat > app/android/keystore.properties << EOF
|
||||
releaseStorePassword=$RELEASE_STORE_PASS
|
||||
releaseKeyPassword=$RELEASE_KEY_PASS
|
||||
debugStorePassword=$DEBUG_STORE_PASS
|
||||
debugKeyPassword=$DEBUG_KEY_PASS
|
||||
EOF
|
||||
# Path must be app/android/keys/keystore.properties (matches build.gradle.kts).
|
||||
# Outer "…" / '…' around values are optional; Gradle strips them.
|
||||
cat > app/android/keys/keystore.properties << EOF
|
||||
releaseStorePassword=$RELEASE_STORE_PASS
|
||||
releaseKeyPassword=$RELEASE_KEY_PASS
|
||||
debugStorePassword=$DEBUG_STORE_PASS
|
||||
debugKeyPassword=$DEBUG_KEY_PASS
|
||||
EOF
|
||||
```
|
||||
|
||||
3. **Open in Android Studio:** `File → Open` → repo root. Gradle syncs dependencies automatically.
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
# FromChat
|
||||
|
||||
FromChat — 100% бесплатный и открытый мессенджер. В этом репозитории — кроссплатформенный клиент (Android + iOS; iOS пока не готов).
|
||||
FromChat — 100% бесплатный и открытый мессенджер. В этом репозитории — кроссплатформенный клиент (Android, Desktop на Compose Multiplatform и iOS).
|
||||
|
||||
[📥 Скачать](https://github.com/fromchat-messenger/android/releases/latest) • [💬 Telegram-канал](https://t.me/fromchat_ch) • [🖥️ Сервер](https://github.com/fromchat-messenger/backend)
|
||||
|
||||
## ✨ Возможности
|
||||
|
||||
- **Голосовые и видеозвонки** — LiveKit
|
||||
- **Демонстрация экрана** во время звонков
|
||||
- **Демонстрация экрана** во время звонков (Android)
|
||||
- **Общий чат** — сообщество пользователей сервера
|
||||
- **Личные сообщения** — легальная схема шифрования; E2EE на сервере планируется
|
||||
- **Управление устройствами** — активные сеансы
|
||||
@@ -18,15 +18,35 @@ FromChat — 100% бесплатный и открытый мессенджер.
|
||||
|
||||
## 📊 Сравнение клиентов
|
||||
|
||||
⚠️ **iOS временно не поддерживается** (ограничения Apple и объём работы). Клиент выйдет позже.
|
||||
| Возможность | Android | Desktop | Web | iOS |
|
||||
|------------------------------------|---------|--------------------------|-----|---------------------|
|
||||
| **Обмен сообщениями и профили** | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Голосовые/видеозвонки** | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Демонстрация экрана** | ✅ | ❌ | ✅ | ❌ |
|
||||
| **Push в фоне** | ✅ (FCM) | ✅ (постоянный WS + tray) | ✅ | ❌ (WS пока открыто) |
|
||||
| **Реакции на сообщения** | ❌ | ❌ | ✅ | ❌ |
|
||||
| **Расширенная поддержка вложений** | ✅ | ✅ | ❌ | ✅ |
|
||||
|
||||
| Возможность | Android | Web | iOS |
|
||||
| --- | --- | --- | --- |
|
||||
| **Обмен сообщениями и профили** | ✅ | ✅ | ❌ |
|
||||
| **Голосовые/видеозвонки** | ✅ | ✅ | ❌ |
|
||||
| **Демонстрация экрана** | ✅ | ✅ | ❌ |
|
||||
| **Реакции на сообщения** | ❌ | ✅ | ❌ |
|
||||
| **Расширенная поддержка вложений** | ✅ | ❌ | ❌ |
|
||||
Desktop заменяет бывший Electron-клиент в репозитории Web. Запуск: `./gradlew :app:desktop:run`.
|
||||
|
||||
### Сборка Desktop
|
||||
|
||||
Релизные пакеты собираются на соответствующей ОС (или в CI):
|
||||
|
||||
| Задача | Хост | Артефакты |
|
||||
|--------|------|-----------|
|
||||
| `./gradlew :app:desktop:packageReleaseMac` | macOS | `FromChat-<ver>-macOS.dmg` |
|
||||
| `./gradlew :app:desktop:packageReleaseLinux` | Linux | `.deb`, `.rpm`, `.AppImage` (нужен `appimagetool`) |
|
||||
| `./gradlew :app:desktop:packageReleaseWindows` | Windows | `FromChat-Setup-<ver>.exe`, `FromChat-Portable-<ver>.exe` (нужен Rust) |
|
||||
| `./gradlew :app:desktop:packageReleaseDesktop` | текущая ОС | делегирует в задачу выше |
|
||||
|
||||
Результаты: `app/desktop/build/distributions/release/`.
|
||||
|
||||
**CI:** `.github/workflows/desktop-release.yml` на тегах `v*` и `workflow_dispatch` (обязательный input `version`), затем загрузка в GitHub Release.
|
||||
|
||||
**Windows portable:** в папке виден только `FromChat Portable.exe`; остальной runtime — Hidden. Данные в Hidden `fromchat-data/` рядом с EXE.
|
||||
|
||||
**Установка:** Windows `%LOCALAPPDATA%\FromChat`, macOS `~/Library/Application Support/FromChat`, Linux `~/.local/share/FromChat` (миграция с `~/.fromchat/cache` при наличии).
|
||||
|
||||
---
|
||||
|
||||
@@ -62,24 +82,24 @@ FromChat — 100% бесплатный и открытый мессенджер.
|
||||
2. **Сгенерируйте ключи (Debug & Release):**
|
||||
|
||||
```bash
|
||||
DEBUG_STORE_PASS=CHANGEME
|
||||
DEBUG_KEY_PASS=CHANGEME
|
||||
RELEASE_STORE_PASS=CHANGEME
|
||||
RELEASE_KEY_PASS=CHANGEME
|
||||
DEBUG_STORE_PASS="CHANGEME"
|
||||
DEBUG_KEY_PASS="CHANGEME"
|
||||
RELEASE_STORE_PASS="CHANGEME"
|
||||
RELEASE_KEY_PASS="CHANGEME"
|
||||
|
||||
mkdir -p app/android/keys
|
||||
|
||||
keytool -genkey -v -keystore app/android/keys/debug.jks \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-alias key0 -storepass $DEBUG_STORE_PASS -keypass $DEBUG_KEY_PASS \
|
||||
-alias key0 -storepass "$DEBUG_STORE_PASS" -keypass "$DEBUG_KEY_PASS" \
|
||||
-dname "CN=Debug, O=FromChat, C=RU"
|
||||
|
||||
keytool -genkey -v -keystore app/android/keys/release.jks \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-alias key0 -storepass $RELEASE_STORE_PASS -keypass $RELEASE_KEY_PASS \
|
||||
-alias key0 -storepass "$RELEASE_STORE_PASS" -keypass "$RELEASE_KEY_PASS" \
|
||||
-dname "CN=Release, O=FromChat, C=RU"
|
||||
|
||||
cat > app/android/keystore.properties << EOF
|
||||
cat > app/android/keys/keystore.properties << EOF
|
||||
releaseStorePassword=$RELEASE_STORE_PASS
|
||||
releaseKeyPassword=$RELEASE_KEY_PASS
|
||||
debugStorePassword=$DEBUG_STORE_PASS
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
- Наконец-то появилась десктопная версия FromChat! Теперь вы можете общаться прямо с вашего компьютера на Windows, Linux и macOS. Даже старые Mac на Intel поддерживаются.
|
||||
- На Android и десктопе вы теперь можете напрямую перетаскивать файлы и картинки прямо в чат без необходимости долго их искать.
|
||||
- Улучшен экран устройств: теперь более наглядно видно, на каких устройствах вы вошли в аккаунт. И иконки теперь везде одинаковые.
|
||||
@@ -56,39 +56,59 @@ val fixComposeResourcesStructure = tasks.register<FixComposeResTask>("fixCompose
|
||||
|
||||
extensions.configure<ApplicationExtension> {
|
||||
namespace = "ru.fromchat"
|
||||
compileSdk = 37
|
||||
compileSdk {
|
||||
version = release(37) {
|
||||
minorApiLevel = 2
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ru.fromchat"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = rootProject.extra["versionCode"] as Int
|
||||
versionName = rootProject.extra["versionName"] as String
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = file("keys/keystore.properties")
|
||||
val hasReleaseKeystore = keystorePropertiesFile.isFile && file("keys/release.jks").isFile
|
||||
val hasDebugKeystore = keystorePropertiesFile.isFile && file("keys/debug.jks").isFile
|
||||
|
||||
signingConfigs {
|
||||
val keystoreProperties = Properties().apply {
|
||||
load(file("keys/keystore.properties").inputStream())
|
||||
}
|
||||
if (hasReleaseKeystore || hasDebugKeystore) {
|
||||
val keystoreProperties = Properties().apply {
|
||||
load(keystorePropertiesFile.inputStream())
|
||||
}
|
||||
|
||||
create("release") {
|
||||
storeFile = file("keys/release.jks")
|
||||
keyAlias = "key0"
|
||||
storePassword = keystoreProperties["releaseStorePassword"].toString()
|
||||
keyPassword = keystoreProperties["releaseKeyPassword"].toString()
|
||||
enableV3Signing = true
|
||||
}
|
||||
fun Properties.password(key: String): String {
|
||||
val raw = getProperty(key)
|
||||
?: error("Missing $key in keys/keystore.properties")
|
||||
return raw.trim().removeSurrounding("\"").removeSurrounding("'")
|
||||
}
|
||||
|
||||
getByName("debug") {
|
||||
storeFile = file("keys/debug.jks")
|
||||
keyAlias = "debug"
|
||||
storePassword = keystoreProperties["debugStorePassword"].toString()
|
||||
keyPassword = keystoreProperties["debugKeyPassword"].toString()
|
||||
enableV3Signing = true
|
||||
if (hasReleaseKeystore) {
|
||||
create("release") {
|
||||
storeFile = file("keys/release.jks")
|
||||
keyAlias = "key0"
|
||||
storePassword = keystoreProperties.password("releaseStorePassword")
|
||||
keyPassword = keystoreProperties.password("releaseKeyPassword")
|
||||
enableV3Signing = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDebugKeystore) {
|
||||
getByName("debug") {
|
||||
storeFile = file("keys/debug.jks")
|
||||
keyAlias = "key0"
|
||||
storePassword = keystoreProperties.password("debugStorePassword")
|
||||
keyPassword = keystoreProperties.password("debugKeyPassword")
|
||||
enableV3Signing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,13 +116,17 @@ extensions.configure<ApplicationExtension> {
|
||||
debug {
|
||||
applicationIdSuffix = ".beta"
|
||||
versionNameSuffix = "-beta"
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
if (hasDebugKeystore) {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
if (hasReleaseKeystore) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
@@ -166,9 +190,4 @@ dependencies {
|
||||
|
||||
implementation(project(":app:shared"))
|
||||
implementation(project(":utils:shared"))
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation(libs.androidx.compose.material3)
|
||||
testImplementation("androidx.graphics:graphics-shapes:1.0.1")
|
||||
testImplementation("org.robolectric:robolectric:4.14.1")
|
||||
}
|
||||
@@ -19,6 +19,12 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
android:name=".App"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_stat_fromchat" />
|
||||
<meta-data
|
||||
android:name="firebase_messaging_installation_id_enabled"
|
||||
android:value="true" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -39,6 +45,15 @@
|
||||
android:host="u"
|
||||
android:pathPrefix="/" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="fromchat"
|
||||
android:host="oauth"
|
||||
android:pathPrefix="/yandex" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".fcm.FromChatFirebaseMessagingService"
|
||||
|
||||
@@ -2,131 +2,24 @@ package ru.fromchat
|
||||
|
||||
import android.app.Application
|
||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.WebSocketManager
|
||||
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
||||
import ru.fromchat.notifications.NotificationHelper
|
||||
|
||||
class App: Application() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun fetchAndNotify(
|
||||
includeDmMessages: Boolean = false,
|
||||
dmMessageId: Int? = null
|
||||
) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
NotificationHelper.fetchAndNotify(
|
||||
applicationContext,
|
||||
includeDmMessages = includeDmMessages,
|
||||
dmMessageId = dmMessageId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
import ru.fromchat.notifications.MessageNotificationCoordinator
|
||||
|
||||
class App : Application() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
UtilsLibrary.init(this)
|
||||
|
||||
WebSocketManager.addGlobalMessageHandler { msg ->
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
|
||||
fun isOwnPublicMessage(data: JsonObject?) =
|
||||
data?.get("user_id")?.jsonPrimitive?.content?.toIntOrNull() == currentUserId
|
||||
|
||||
fun isOwnDmMessage(data: JsonObject?) =
|
||||
data?.get("senderId")?.jsonPrimitive?.content?.toIntOrNull() == currentUserId
|
||||
|
||||
when (msg.type) {
|
||||
"newMessage" -> {
|
||||
if (!isOwnPublicMessage(msg.data?.jsonObject)) {
|
||||
fetchAndNotify()
|
||||
}
|
||||
}
|
||||
|
||||
"dmNew" -> {
|
||||
if (!isOwnDmMessage(msg.data?.jsonObject)) {
|
||||
fetchAndNotify(
|
||||
includeDmMessages = true,
|
||||
dmMessageId = msg
|
||||
.data
|
||||
?.jsonObject
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?.toIntOrNull()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"updates" -> {
|
||||
msg.data?.jsonObject?.get("updates")?.jsonArray?.let { updates ->
|
||||
var shouldFetchPublic = false
|
||||
var shouldFetchDm = false
|
||||
var latestDmMessageId: Int? = null
|
||||
|
||||
for (item in updates) {
|
||||
val (type, data) = item.jsonObject.let {
|
||||
Pair(
|
||||
it["type"]?.jsonPrimitive?.content,
|
||||
it["data"]?.jsonObject
|
||||
)
|
||||
}
|
||||
|
||||
when (type) {
|
||||
"newMessage" -> {
|
||||
if (!isOwnPublicMessage(data)) {
|
||||
shouldFetchPublic = true
|
||||
}
|
||||
}
|
||||
|
||||
"dmNew" -> {
|
||||
if (!isOwnDmMessage(data)) {
|
||||
shouldFetchDm = true
|
||||
|
||||
data
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?.toIntOrNull()
|
||||
?.let {
|
||||
latestDmMessageId = it.coerceAtLeast(
|
||||
latestDmMessageId ?: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldFetchPublic || shouldFetchDm) {
|
||||
fetchAndNotify(
|
||||
includeDmMessages = shouldFetchDm,
|
||||
dmMessageId = latestDmMessageId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageNotificationCoordinator.install()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching { ApiClient.loadPersistedData() }
|
||||
AttachmentTransferBootstrap.launchOnApplicationStart()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,18 +15,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.schema.messages.MessagesResponse
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
||||
import ru.fromchat.notifications.NotificationLaunchTarget
|
||||
import ru.fromchat.ui.App
|
||||
@@ -34,7 +23,6 @@ import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
|
||||
|
||||
private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type"
|
||||
private const val EXTRA_OPEN_DM_USER_ID = "open_dm_user_id"
|
||||
private const val EXTRA_MARK_MESSAGE_READ = "mark_message_read"
|
||||
private const val EXTRA_MESSAGE_ID = "scroll_to_message_id"
|
||||
private const val CHAT_TYPE_PUBLIC = "public"
|
||||
private const val CHAT_TYPE_DM = "dm"
|
||||
@@ -81,10 +69,6 @@ class MainActivity : ComponentActivity() {
|
||||
"handleIntent parsedProfileTarget: userId=${profileTarget?.userId}, username=${profileTarget?.username}, parseError=${profileTarget?.parseError}"
|
||||
)
|
||||
|
||||
if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) {
|
||||
markMessagesAsRead()
|
||||
}
|
||||
|
||||
val baseState = ProfileDeepLinkResolution(
|
||||
scrollToMessageId = if (messageId != -1) messageId else null,
|
||||
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM,
|
||||
@@ -183,30 +167,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun markMessagesAsRead() {
|
||||
GlobalScope.launch {
|
||||
try {
|
||||
// Get all unread messages and mark them as read
|
||||
val messageIds = ApiClient.http
|
||||
.get("${ServerConfig.apiBaseUrl}/messages/new")
|
||||
.body<MessagesResponse>()
|
||||
.messages
|
||||
.map { it.id }
|
||||
|
||||
if (messageIds.isNotEmpty()) {
|
||||
ApiClient.http.post("${ServerConfig.apiBaseUrl}/messages/read") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(mapOf("messageIds" to messageIds))
|
||||
}
|
||||
Logger.i("MainActivity", "Marked ${messageIds.size} messages as read")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("MainActivity", "Failed to mark messages as read", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkGooglePlayServices(): Boolean {
|
||||
with (GoogleApiAvailability.getInstance()) {
|
||||
val resultCode = isGooglePlayServicesAvailable(this@MainActivity)
|
||||
@@ -228,6 +188,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Logger.i("MainActivity", "onCreate savedInstanceStateNull=${savedInstanceState == null}")
|
||||
super.onCreate(savedInstanceState)
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
@@ -264,13 +225,25 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
Logger.i("MainActivity", "onPause")
|
||||
super.onPause()
|
||||
prevIsPublicChatVisible = isPublicChatVisible
|
||||
isPublicChatVisible = false
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
Logger.i("MainActivity", "onResume")
|
||||
super.onResume()
|
||||
isPublicChatVisible = prevIsPublicChatVisible ?: false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Logger.i("MainActivity", "onDestroy isFinishing=$isFinishing")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
Logger.i("MainActivity", "onSaveInstanceState")
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.notifications.NotificationHelper
|
||||
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
|
||||
import ru.fromchat.notifications.MessageNotificationCoordinator
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
@@ -27,44 +27,24 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
val fallbackMessageId = pushData["message_id"]?.toIntOrNull()
|
||||
?: pushData["dm_id"]?.toIntOrNull()
|
||||
val senderId = pushData["sender_id"]?.toIntOrNull()
|
||||
val sender = pushData["sender_username"] ?: remoteMessage.data["senderUsername"]
|
||||
val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat"
|
||||
val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message"
|
||||
val messageType = pushData["type"] ?: "public_message"
|
||||
val isDirectMessage = messageType.equals("dm", ignoreCase = true)
|
||||
if (ApiClient.token.isNullOrBlank()) {
|
||||
Logger.w("FromChatFCM", "No auth token in memory; loading persisted data before handling push")
|
||||
ApiClient.loadPersistedData()
|
||||
Logger.d(
|
||||
"FromChatFCM",
|
||||
"Token loaded from storage for push sync: hasToken=${ApiClient.token?.isNotBlank() ?: false}",
|
||||
)
|
||||
}
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
if (senderId != null && senderId == currentUserId) {
|
||||
Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
|
||||
return@launch
|
||||
}
|
||||
if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) {
|
||||
NotificationHelper.showFallbackPushNotification(
|
||||
context = applicationContext,
|
||||
title = title,
|
||||
body = body,
|
||||
sender = sender,
|
||||
messageId = fallbackMessageId,
|
||||
isDirectMessage = false,
|
||||
senderId = senderId,
|
||||
)
|
||||
}
|
||||
if (isDirectMessage) {
|
||||
NotificationHelper.fetchAndNotify(
|
||||
applicationContext,
|
||||
MessageNotificationCoordinator.fetchAndNotify(
|
||||
includeDmMessages = true,
|
||||
dmMessageId = fallbackMessageId,
|
||||
dmSenderName = sender,
|
||||
)
|
||||
} else {
|
||||
NotificationHelper.fetchAndNotify(applicationContext)
|
||||
MessageNotificationCoordinator.schedulePublicFetchAndNotify()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
|
||||
@@ -72,18 +52,16 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
Logger.i("FromChatFCM", "onNewToken received (...${token.takeLast(8)})")
|
||||
override fun onRegistered(installationId: String) {
|
||||
Logger.i("FromChatFCM", "onRegistered received (...${installationId.takeLast(8)})")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
settings.putString("pending_fcm_token", token)
|
||||
settings.putString("pending_fcm_token", installationId)
|
||||
uploadPendingFcmTokenIfAvailable()
|
||||
Logger.i("FromChatFCM", "FCM token queued or uploaded for this app instance")
|
||||
Logger.i("FromChatFCM", "FCM installation id queued or uploaded for this app instance")
|
||||
} catch (e: Exception) {
|
||||
Logger.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
|
||||
Logger.e("FromChatFCM", "onRegistered upload error: ${e.message}", e)
|
||||
}
|
||||
|
||||
super.onNewToken(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,573 +0,0 @@
|
||||
package ru.fromchat.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import io.ktor.client.request.get
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.MainActivity
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.R
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
||||
import ru.fromchat.api.local.messages.buildChatListPreview
|
||||
import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.api.schema.messages.MessagesResponse
|
||||
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
|
||||
import ru.fromchat.api.crypto.DmCiphertextCorruptedException
|
||||
import ru.fromchat.api.crypto.decryptEnvelope
|
||||
import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
|
||||
import kotlin.time.Instant
|
||||
|
||||
object NotificationHelper {
|
||||
private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type"
|
||||
private const val EXTRA_OPEN_DM_USER_ID = "open_dm_user_id"
|
||||
private const val EXTRA_REPLY_CHAT_TYPE = "notification_reply_chat_type"
|
||||
private const val EXTRA_REPLY_DM_USER_ID = "notification_reply_dm_user_id"
|
||||
private const val EXTRA_REPLY_PARENT_MESSAGE_ID = "notification_reply_parent_message_id"
|
||||
private const val EXTRA_MESSAGE_ID = "scroll_to_message_id"
|
||||
private const val EXTRA_MARK_MESSAGE_READ = "mark_message_read"
|
||||
private const val CHAT_TYPE_PUBLIC = "public"
|
||||
private const val CHAT_TYPE_DM = "dm"
|
||||
private const val CHANNEL_ID = "fromchat_messages"
|
||||
private const val SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary
|
||||
private const val PREF_SHOWN_KEY = "shown_message_ids"
|
||||
private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids"
|
||||
private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id"
|
||||
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
|
||||
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||
|
||||
private fun listPreviewStrings(context: Context): ChatListPreviewStrings {
|
||||
val emoji = context.getString(R.string.chat_preview_image_emoji)
|
||||
return ChatListPreviewStrings(
|
||||
imageEmoji = emoji,
|
||||
imageOnly = context.getString(R.string.chat_preview_image, emoji),
|
||||
attachmentOnly = context.getString(R.string.chat_preview_attachment),
|
||||
)
|
||||
}
|
||||
|
||||
private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String =
|
||||
buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content
|
||||
|
||||
fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID
|
||||
|
||||
private fun createMessageIntent(
|
||||
context: Context,
|
||||
messageId: Int,
|
||||
targetDmUserId: Int? = null,
|
||||
markMessageRead: Boolean = true
|
||||
) = PendingIntent.getActivity(
|
||||
context,
|
||||
if (targetDmUserId != null) -messageId else messageId,
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_MESSAGE_ID, messageId)
|
||||
putExtra(
|
||||
EXTRA_NOTIFICATION_CHAT_TYPE,
|
||||
if (targetDmUserId != null) CHAT_TYPE_DM else CHAT_TYPE_PUBLIC
|
||||
)
|
||||
putExtra(EXTRA_OPEN_DM_USER_ID, targetDmUserId ?: -1)
|
||||
putExtra(EXTRA_MARK_MESSAGE_READ, markMessageRead)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
private fun notificationReplyAction(context: Context) =
|
||||
"${context.packageName}.NOTIFICATION_REPLY"
|
||||
|
||||
private fun createReplyIntent(
|
||||
context: Context,
|
||||
isDirectMessage: Boolean = false,
|
||||
targetDmUserId: Int? = null,
|
||||
parentMessageId: Int? = null
|
||||
) = PendingIntent.getBroadcast(
|
||||
context,
|
||||
if (isDirectMessage && targetDmUserId != null) {
|
||||
-targetDmUserId
|
||||
} else {
|
||||
parentMessageId ?: SUMMARY_NOTIFICATION_ID
|
||||
},
|
||||
Intent(context, NotificationReplyReceiver::class.java).apply {
|
||||
action = notificationReplyAction(context)
|
||||
putExtra("notification_id", SUMMARY_NOTIFICATION_ID)
|
||||
putExtra(EXTRA_REPLY_CHAT_TYPE, if (isDirectMessage) CHAT_TYPE_DM else CHAT_TYPE_PUBLIC)
|
||||
putExtra(EXTRA_REPLY_DM_USER_ID, targetDmUserId ?: -1)
|
||||
if (parentMessageId != null) {
|
||||
putExtra(EXTRA_REPLY_PARENT_MESSAGE_ID, parentMessageId)
|
||||
}
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
(context
|
||||
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
).createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Messages",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "FromChat message notifications"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAndNotify(
|
||||
context: Context,
|
||||
includeDmMessages: Boolean = false,
|
||||
dmMessageId: Int? = null,
|
||||
dmSenderName: String? = null
|
||||
) {
|
||||
Logger.i("NotificationHelper", "fetchAndNotify: starting fetch")
|
||||
|
||||
try {
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"fetchAndNotify: currentUserId=$currentUserId hasToken=${ApiClient.token?.isNotBlank() ?: false}"
|
||||
)
|
||||
if (currentUserId == -1) {
|
||||
Logger.w("NotificationHelper", "fetchAndNotify: missing currentUserId, skipping push sync")
|
||||
return
|
||||
}
|
||||
|
||||
val messages = ApiClient.http
|
||||
.get("${ServerConfig.apiBaseUrl}/messages/new")
|
||||
.body<MessagesResponse>()
|
||||
.messages
|
||||
.filter { it.user_id != currentUserId }
|
||||
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
|
||||
if (messages.isNotEmpty()) {
|
||||
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
createChannel(context)
|
||||
displayNotifications(context, messages)
|
||||
}
|
||||
} else {
|
||||
Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned")
|
||||
}
|
||||
|
||||
if (includeDmMessages) {
|
||||
fetchAndNotifyDirectMessages(context, currentUserId, dmMessageId, dmSenderName)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is ClientRequestException && e.response.status.value == 401) {
|
||||
try {
|
||||
Logger.w("NotificationHelper", "fetchAndNotify: received 401; reloading token and retrying")
|
||||
ApiClient.loadPersistedData()
|
||||
val retryMessages = ApiClient.http
|
||||
.get("${ServerConfig.apiBaseUrl}/messages/new")
|
||||
.body<MessagesResponse>()
|
||||
.messages
|
||||
.filter { it.user_id != settings.getInt("current_user_id", -1) }
|
||||
Logger.i(
|
||||
"NotificationHelper",
|
||||
"fetchAndNotify retry: fetched ${retryMessages.size} public messages"
|
||||
)
|
||||
if (retryMessages.isNotEmpty()) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
createChannel(context)
|
||||
displayNotifications(context, retryMessages)
|
||||
}
|
||||
}
|
||||
if (includeDmMessages) {
|
||||
fetchAndNotifyDirectMessages(context, settings.getInt("current_user_id", -1), dmMessageId, dmSenderName)
|
||||
}
|
||||
return
|
||||
} catch (_: Exception) {
|
||||
Logger.e("NotificationHelper", "fetchAndNotify retry failed", e)
|
||||
}
|
||||
}
|
||||
Logger.e("NotificationHelper", "fetchAndNotify: error ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchAndNotifyDirectMessages(
|
||||
context: Context,
|
||||
currentUserId: Int,
|
||||
dmMessageId: Int? = null,
|
||||
dmSenderName: String? = null
|
||||
) {
|
||||
val storedLastDmMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0)
|
||||
val sinceId = when {
|
||||
dmMessageId != null && dmMessageId > storedLastDmMessageId -> dmMessageId - 1
|
||||
storedLastDmMessageId > 0 -> storedLastDmMessageId
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (sinceId == null || sinceId < 0) {
|
||||
Logger.d("NotificationHelper", "fetchAndNotifyDirectMessages: no dm watermark yet, skipping broad dm sync")
|
||||
return
|
||||
}
|
||||
|
||||
val response = runCatching {
|
||||
ApiClient.getDmFetch(sinceId)
|
||||
}.getOrElse { throwable ->
|
||||
if (throwable is ClientRequestException && throwable.response.status.value == 401) {
|
||||
throw throwable
|
||||
}
|
||||
Logger.e(
|
||||
"NotificationHelper",
|
||||
"fetchAndNotifyDirectMessages: failed to fetch dm messages for since=$sinceId: ${throwable.message}",
|
||||
throwable
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
processDirectMessages(context, response, currentUserId, dmMessageId, dmSenderName)
|
||||
}
|
||||
|
||||
private suspend fun processDirectMessages(
|
||||
context: Context,
|
||||
response: DmHistoryResponse,
|
||||
currentUserId: Int,
|
||||
dmMessageId: Int?,
|
||||
dmSenderName: String?
|
||||
) {
|
||||
val dmMessages = response.messages
|
||||
Logger.i("NotificationHelper", "fetchAndNotifyDirectMessages: fetched ${dmMessages.size} dm messages")
|
||||
if (dmMessages.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val shownDm = settings.getStringSet(PREF_SHOWN_DM_KEY, emptySet()).toMutableSet()
|
||||
val latestMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0)
|
||||
|
||||
val previewStrings = listPreviewStrings(context)
|
||||
|
||||
dmMessages
|
||||
.filter { envelope ->
|
||||
envelope.id > 0 && envelope.senderId != currentUserId
|
||||
}
|
||||
.forEach { envelope ->
|
||||
val envelopeId = envelope.id
|
||||
val shownDmKey = "dm:$envelopeId"
|
||||
|
||||
if (shownDm.contains(shownDmKey) || envelopeId <= latestMessageId) {
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"Direct notification skipped: already shown envelopeId=$envelopeId"
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val plaintext = runCatching {
|
||||
decryptEnvelope(envelope, currentUserId)
|
||||
}.getOrElse { throwable ->
|
||||
when (throwable) {
|
||||
is DmCiphertextCorruptedException -> {
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"DM decrypt failed for envelopeId=$envelopeId"
|
||||
)
|
||||
CorruptedDmMessagePlaceholder
|
||||
}
|
||||
|
||||
else -> {
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"DM decrypt failed for envelopeId=$envelopeId: ${throwable.message}",
|
||||
throwable
|
||||
)
|
||||
"Encrypted message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val senderName = when {
|
||||
envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName
|
||||
!envelope.senderUsername.isNullOrBlank() -> envelope.senderUsername
|
||||
else -> ProfileCache.get(envelope.senderId)
|
||||
?.visibleDisplayName(currentUserId)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}.orEmpty()
|
||||
val dmConversationUserId = envelope.senderId
|
||||
val notificationBody = buildChatListPreviewFromEnvelope(
|
||||
envelope = envelope,
|
||||
decryptedPlaintext = plaintext,
|
||||
strings = previewStrings,
|
||||
)?.takeIf { it.isNotBlank() } ?: plaintext
|
||||
|
||||
showFallbackPushNotification(
|
||||
context = context,
|
||||
title = if (senderName.isNotBlank()) {
|
||||
"Direct message from $senderName"
|
||||
} else {
|
||||
"Direct message"
|
||||
},
|
||||
body = notificationBody,
|
||||
sender = senderName,
|
||||
messageId = envelopeId,
|
||||
allowWhenPublicChatVisible = true,
|
||||
isDirectMessage = true,
|
||||
targetDmUserId = dmConversationUserId,
|
||||
conversationTitle = "Direct Messages"
|
||||
)
|
||||
shownDm.add(shownDmKey)
|
||||
}
|
||||
|
||||
val newMaxDmId = dmMessages.maxOfOrNull { it.id } ?: 0
|
||||
if (newMaxDmId > latestMessageId) {
|
||||
settings.putInt(PREF_LAST_DM_MESSAGE_ID, newMaxDmId)
|
||||
}
|
||||
settings.putStringSet(PREF_SHOWN_DM_KEY, shownDm)
|
||||
}
|
||||
|
||||
fun showFallbackPushNotification(
|
||||
context: Context,
|
||||
title: String,
|
||||
body: String,
|
||||
sender: String? = null,
|
||||
messageId: Int? = null,
|
||||
allowWhenPublicChatVisible: Boolean = false,
|
||||
isDirectMessage: Boolean = false,
|
||||
targetDmUserId: Int? = null,
|
||||
conversationTitle: String = "Public Chat",
|
||||
senderId: Int? = null,
|
||||
) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
createChannel(context)
|
||||
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
if (!isDirectMessage && senderId != null && senderId == currentUserId) {
|
||||
Logger.d("NotificationHelper", "Fallback push skipped: own public message senderId=$senderId")
|
||||
return@launch
|
||||
}
|
||||
if (isDirectMessage && targetDmUserId != null && targetDmUserId == currentUserId) {
|
||||
Logger.d("NotificationHelper", "Fallback push skipped: own DM targetDmUserId=$targetDmUserId")
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (isPublicChatVisible && !allowWhenPublicChatVisible) {
|
||||
Logger.d("NotificationHelper", "Fallback push notification skipped: public chat is visible")
|
||||
return@launch
|
||||
}
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
if (
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"Fallback push notification skipped: POST_NOTIFICATIONS permission missing"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
||||
val shownKey = if (isDirectMessage) "dm:${messageId}" else messageId?.toString()
|
||||
if (messageId != null && shown.contains(shownKey)) {
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"Fallback push notification skipped: already shown messageId=$messageId"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
if (messageId != null) {
|
||||
shownKey?.let { shown.add(it) }
|
||||
}
|
||||
|
||||
val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat"
|
||||
notify(
|
||||
SUMMARY_NOTIFICATION_ID,
|
||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.logo_big)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(
|
||||
NotificationCompat.MessagingStyle(
|
||||
Person.Builder().setName("FromChat").build()
|
||||
).setConversationTitle(conversationTitle).addMessage(
|
||||
NotificationCompat.MessagingStyle.Message(
|
||||
body,
|
||||
System.currentTimeMillis(),
|
||||
Person.Builder().setName(senderName).build()
|
||||
)
|
||||
)
|
||||
)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true)
|
||||
.addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_menu_send,
|
||||
"Reply",
|
||||
createReplyIntent(
|
||||
context = context,
|
||||
isDirectMessage = isDirectMessage,
|
||||
targetDmUserId = targetDmUserId,
|
||||
parentMessageId = messageId
|
||||
)
|
||||
)
|
||||
.addRemoteInput(
|
||||
RemoteInput.Builder(KEY_TEXT_REPLY)
|
||||
.setLabel("Reply to chat...")
|
||||
.build()
|
||||
)
|
||||
.setAllowGeneratedReplies(true)
|
||||
.build()
|
||||
)
|
||||
.setContentIntent(
|
||||
createMessageIntent(
|
||||
context = context,
|
||||
messageId = messageId ?: 0,
|
||||
targetDmUserId = targetDmUserId,
|
||||
markMessageRead = !isDirectMessage
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
||||
Logger.i(
|
||||
"NotificationHelper",
|
||||
"Fallback push notification shown messageId=$messageId"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun displayNotifications(context: Context, messages: List<Message>) {
|
||||
Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages")
|
||||
|
||||
// Don't show notifications if user is currently viewing the public chat
|
||||
if (isPublicChatVisible) {
|
||||
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
|
||||
return
|
||||
}
|
||||
|
||||
GlobalScope.launch {
|
||||
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
||||
var newMessageCount = 0
|
||||
val previewStrings = listPreviewStrings(context)
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
if (
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
// Find new messages that are not from the current user
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
|
||||
if (currentUserId == -1) return@launch
|
||||
|
||||
val newMessages = messages.filter { msg ->
|
||||
!shown.contains(msg.id.toString()) && // Not already shown
|
||||
msg.user_id != currentUserId // Not from current user
|
||||
}
|
||||
if (newMessages.isEmpty()) {
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"displayNotifications: no new messages after filters for user=$currentUserId"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
newMessages.apply { forEach { shown.add(it.id.toString()) } }
|
||||
|
||||
newMessageCount = newMessages.size
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}"
|
||||
)
|
||||
|
||||
notify(
|
||||
SUMMARY_NOTIFICATION_ID,
|
||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.logo_big)
|
||||
.setStyle(
|
||||
NotificationCompat.MessagingStyle(
|
||||
Person.Builder().setName("FromChat").build()
|
||||
).setConversationTitle("Public Chat").let { style ->
|
||||
for (msg in newMessages.takeLast(10)) {
|
||||
val timestamp = try {
|
||||
Instant.parse(msg.timestamp).toEpochMilliseconds()
|
||||
} catch (_: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
|
||||
style.addMessage(
|
||||
NotificationCompat.MessagingStyle.Message(
|
||||
notificationBodyForMessage(msg, previewStrings),
|
||||
timestamp,
|
||||
Person.Builder()
|
||||
.setName(msg.username)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
style
|
||||
}
|
||||
)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true)
|
||||
.addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_menu_send,
|
||||
"Reply",
|
||||
createReplyIntent(
|
||||
context = context,
|
||||
isDirectMessage = false,
|
||||
parentMessageId = newMessages.last().id
|
||||
)
|
||||
)
|
||||
.addRemoteInput(
|
||||
RemoteInput.Builder(KEY_TEXT_REPLY)
|
||||
.setLabel("Reply to chat...")
|
||||
.build()
|
||||
)
|
||||
.setAllowGeneratedReplies(true)
|
||||
.build()
|
||||
)
|
||||
.setContentIntent(createMessageIntent(context, newMessages.last().id))
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
||||
Logger.i("NotificationHelper", "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
)
|
||||
|
||||
val replyText = RemoteInput.getResultsFromIntent(intent)?.let { input ->
|
||||
(input.getCharSequence(NotificationHelper.KEY_TEXT_REPLY)
|
||||
(input.getCharSequence(KEY_TEXT_REPLY)
|
||||
?: input.getCharSequence("key_text_reply"))
|
||||
}
|
||||
?.toString()
|
||||
@@ -47,7 +47,11 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val parentMessageId = intent.getIntExtra(EXTRA_REPLY_PARENT_MESSAGE_ID, -1).takeIf { it > 0 }
|
||||
|
||||
Logger.d("NotificationReply", "Received reply for $chatType (length=${replyText.length})")
|
||||
NotificationManagerCompat.from(context).cancel(NotificationHelper.summaryNotificationId())
|
||||
val notificationId = intent.getIntExtra("notification_id", 0)
|
||||
if (intent.hasExtra("notification_id")) {
|
||||
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||
}
|
||||
MessageNotificationCoordinator.dismissAll()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="1000"
|
||||
android:viewportHeight="1000">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M494,221.5c43.2,0 85.8,0.1 128.4,-0 42.7,-0.1 81.5,12.2 114.8,39.8 30.5,25.2 49.7,58 57,97.6 1.8,9.4 2.6,19.1 2.6,28.7 0.3,47.8 0,95.6 0.1,143.4 0.1,32.6 -9.9,61.9 -28.3,88.2 -21.2,30.3 -48.8,52.2 -83.2,64.2 -12.9,4.5 -26.9,7.5 -40.5,7.6 -95.6,0.6 -191.2,0.2 -286.8,0.6 -5.2,0 -11.3,2.8 -15.6,6.2 -22.2,17.2 -43.8,35.1 -65.8,52.5 -10.9,8.6 -21.9,17.4 -33.8,24.4 -14.4,8.4 -31.4,2.4 -37.5,-12.1 -1.8,-4.1 -2.1,-9.2 -2.1,-13.8 -0.1,-97.8 -0.2,-195.6 -0.2,-293.4 0,-24.8 -0.9,-49.6 0.3,-74.4 1.8,-37.5 14.7,-71 39.1,-99.2 31.7,-36.6 71.7,-57 119.4,-59.2 38.1,-1.7 76.3,-0.9 114.5,-1.1 5.6,-0 11.1,0 17.3,0M330.4,269.1c-16,5.5 -30.2,14.3 -43,25.6 -30.2,26.8 -46.7,60.7 -46.8,101.7 -0.4,108 -0.2,216 -0.2,324 0,2.5 0.2,4.9 0.4,8.1 1.5,-0.8 2.4,-1.1 3.1,-1.6 22.5,-18 45.5,-35.4 67.3,-54.3 15.9,-13.9 33.5,-19.1 54,-19 88.1,0.4 176.1,0.1 264.2,0.2 22.8,0 44,-6.1 63.9,-17 22.3,-12.2 40.4,-29.1 52.5,-52.1 11.1,-21.2 14.6,-44 14.3,-67.8 -0.4,-42.7 0.2,-85.3 -1.3,-128 -1.1,-34.2 -15.6,-63.3 -39.1,-87.4 -24.5,-25.1 -54,-39.6 -88.9,-39.8 -84,-0.4 -168.1,-0.1 -252.1,-0.2 -16.3,0 -32.1,2 -48.4,7.7h0Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M401.4,512.8c-30.4,8.6 -59,-5.7 -73.3,-29.9 -13.2,-22.3 -11,-51.9 4.9,-72.2 23.3,-29.7 68.1,-35.5 96.6,-8.4 23.3,22.2 27,58.5 7,85.5 -8.9,12.1 -20.5,20.5 -35.2,25.1h0Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M484.5,471.4c-9.1,-6.2 -12.4,-14.2 -9.9,-22.8 2,-7 10.4,-13.8 20,-13.7 39.3,0.4 78.7,0.2 118,0.2h7.3c0.3,-0.5 0.5,-1 0.8,-1.5 -3.5,-3.7 -6.9,-7.5 -10.4,-11.2 -7.9,-8.4 -8.3,-20 -0.9,-27.5 8.2,-8.2 21.2,-8.7 29.1,-0.7 15.2,15.3 30.3,30.7 45.3,46.1 8.1,8.4 8.4,20.7 0.3,29.1 -16.3,16.7 -32.8,33.1 -49.3,49.5 -6.1,6.1 -18.3,5.5 -24.8,-0.8 -6.9,-6.7 -8,-17.9 -2,-25 4.5,-5.3 9.7,-10.1 14.5,-15.1 1.1,-1.2 2.2,-2.4 4.2,-4.6 -3.3,-0.2 -5.3,-0.4 -7.3,-0.4 -42,0 -84.1,0.1 -126.1,-0 -2.8,0 -5.6,-0.9 -8.8,-1.5h-0Z" />
|
||||
</vector>
|
||||
@@ -1,5 +1,11 @@
|
||||
<resources>
|
||||
<string name="public_chat">Общий чат</string>
|
||||
<string name="chat_preview_attachment">Вложение</string>
|
||||
<string name="chat_preview_image_emoji">📷</string>
|
||||
<string name="chat_preview_image">%1$s 1 фото</string>
|
||||
<string name="notification_reply">Ответить</string>
|
||||
<string name="notification_reply_hint">Ответ в чат…</string>
|
||||
<string name="notification_direct_message">Личное сообщение</string>
|
||||
<string name="notification_direct_message_from">Личное сообщение от %1$s</string>
|
||||
<string name="notification_direct_messages_title">Личные сообщения</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">FromChat</string>
|
||||
<string name="public_chat">Main chat</string>
|
||||
<string name="chat_preview_attachment">Attachment</string>
|
||||
<string name="chat_preview_image_emoji">📷</string>
|
||||
<string name="chat_preview_image">%1$s 1 photo</string>
|
||||
<string name="notification_reply">Reply</string>
|
||||
<string name="notification_reply_hint">Reply to chat…</string>
|
||||
<string name="notification_direct_message">Direct message</string>
|
||||
<string name="notification_direct_message_from">Direct message from %1$s</string>
|
||||
<string name="notification_direct_messages_title">Direct Messages</string>
|
||||
</resources>
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"appLabel": "FromChat",
|
||||
"applicationsLabel": "Программы",
|
||||
"primaryColor": "#dabaf9",
|
||||
"titleBarInset": 22,
|
||||
"windowChromeBottom": 22
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Export the DMG artboard to PNG + icon slot positions for create-dmg.
|
||||
*
|
||||
* Args: <stageDir> <distDir>
|
||||
*
|
||||
* Outputs under distDir:
|
||||
* dmg-background.png
|
||||
* dmg-background@2x.png
|
||||
* icon-positions.json
|
||||
*/
|
||||
|
||||
import { chromium } from "playwright";
|
||||
import { createServer } from "node:http";
|
||||
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readFile as readFileSync } from "node:fs/promises";
|
||||
|
||||
const [stageArg, distArg] = process.argv.slice(2);
|
||||
if (!stageArg || !distArg) {
|
||||
console.error("Usage: node export.mjs <stageDir> <distDir>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ROOT = path.resolve(stageArg);
|
||||
const DIST = path.resolve(distArg);
|
||||
const CONFIG_PATH = path.join(ROOT, "dmg-config.json");
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".ttf": "font/ttf",
|
||||
};
|
||||
|
||||
function startStaticServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
||||
const rel = urlPath === "/" ? "/index.html" : urlPath;
|
||||
const filePath = path.normalize(path.join(ROOT, rel));
|
||||
if (!filePath.startsWith(ROOT)) {
|
||||
res.writeHead(403).end("Forbidden");
|
||||
return;
|
||||
}
|
||||
const data = await readFile(filePath);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
res.writeHead(200, {
|
||||
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(data);
|
||||
} catch {
|
||||
res.writeHead(404).end("Not found");
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
reject(new Error("Failed to bind static server"));
|
||||
return;
|
||||
}
|
||||
resolve({ server, port: addr.port });
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function round(n) {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
async function measureSlots(page) {
|
||||
return page.evaluate(() => {
|
||||
const artboard = document.querySelector("#dmg");
|
||||
if (!artboard) throw new Error("#dmg not found");
|
||||
const artRect = artboard.getBoundingClientRect();
|
||||
const measureSlot = (id) => {
|
||||
const el = document.querySelector("#" + id);
|
||||
if (!el) throw new Error("#" + id + " not found");
|
||||
const r = el.getBoundingClientRect();
|
||||
const width = r.width;
|
||||
const height = r.height;
|
||||
const x = r.left - artRect.left;
|
||||
const y = r.top - artRect.top;
|
||||
return {
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
centerX: x + width / 2,
|
||||
centerY: y + height / 2,
|
||||
};
|
||||
};
|
||||
return {
|
||||
artboard: {
|
||||
width: artboard.clientWidth,
|
||||
height: artboard.clientHeight,
|
||||
},
|
||||
slots: {
|
||||
app: measureSlot("slot-app"),
|
||||
applications: measureSlot("slot-applications"),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildPositionsJson(measured) {
|
||||
const { artboard, slots } = measured;
|
||||
const app = slots.app;
|
||||
const applications = slots.applications;
|
||||
return {
|
||||
canvas: {
|
||||
width: round(artboard.width),
|
||||
height: round(artboard.height),
|
||||
background: "dist/dmg-background.png",
|
||||
background2x: "dist/dmg-background@2x.png",
|
||||
},
|
||||
// Include both the standard "Applications" name and the localized "Программы"
|
||||
// so create-dmg (Finder) receives the expected English link while we also
|
||||
// keep the localized name for our records and packaging logic.
|
||||
icons: {
|
||||
"FromChat.app": {
|
||||
x: round(app.centerX),
|
||||
y: round(app.centerY),
|
||||
width: round(app.width),
|
||||
height: round(app.height),
|
||||
slotId: "slot-app",
|
||||
},
|
||||
"Applications": {
|
||||
x: round(applications.centerX),
|
||||
y: round(applications.centerY),
|
||||
width: round(applications.width),
|
||||
height: round(applications.height),
|
||||
slotId: "slot-applications",
|
||||
},
|
||||
"Программы": {
|
||||
x: round(applications.centerX),
|
||||
y: round(applications.centerY),
|
||||
width: round(applications.width),
|
||||
height: round(applications.height),
|
||||
slotId: "slot-applications",
|
||||
},
|
||||
},
|
||||
createDmg: {
|
||||
windowSize: [round(artboard.width), round(artboard.height)],
|
||||
iconSize: Math.round(Math.min(app.width, app.height) * 0.88),
|
||||
icons: [
|
||||
["FromChat.app", round(app.centerX), round(app.centerY)],
|
||||
["Applications", round(applications.centerX), round(applications.centerY)],
|
||||
["Программы", round(applications.centerX), round(applications.centerY)],
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function applyConfig(page, config) {
|
||||
const windowChromeBottom = config.windowChromeBottom ?? config.titleBarInset ?? 22;
|
||||
await page.evaluate(
|
||||
({ bottom, primaryColor }) => {
|
||||
document.documentElement.style.setProperty("--window-chrome-bottom", `${bottom}px`);
|
||||
if (primaryColor) {
|
||||
document.documentElement.style.setProperty("--primary-pill", primaryColor);
|
||||
}
|
||||
},
|
||||
{ bottom: windowChromeBottom, primaryColor: config.primaryColor },
|
||||
);
|
||||
return windowChromeBottom;
|
||||
}
|
||||
|
||||
async function applyLabelsAndMeasure(page, config, measured) {
|
||||
// Set labels from config into DOM, measure their rendered widths and apply inline styles,
|
||||
// then position the label pills centered under measured slot centers.
|
||||
return page.evaluate(
|
||||
({ cfg, measured }) => {
|
||||
document.documentElement.style.setProperty("--primary-pill", cfg.primaryColor || "#7e22ce");
|
||||
|
||||
const setLabel = (selector, text) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return null;
|
||||
el.textContent = text;
|
||||
return el;
|
||||
};
|
||||
|
||||
const appLabel = setLabel('.slot__label[data-for="slot-app"]', cfg.appLabel || "FromChat");
|
||||
const appsLabel = setLabel('.slot__label[data-for="slot-applications"]', cfg.applicationsLabel || "Программы");
|
||||
|
||||
const measureTextWidth = (el) => {
|
||||
if (!el) return 0;
|
||||
const span = document.createElement("span");
|
||||
span.style.visibility = "hidden";
|
||||
span.style.position = "absolute";
|
||||
span.style.whiteSpace = "nowrap";
|
||||
span.style.font = window.getComputedStyle(el).font;
|
||||
span.textContent = el.textContent;
|
||||
document.body.appendChild(span);
|
||||
const w = Math.ceil(span.getBoundingClientRect().width);
|
||||
document.body.removeChild(span);
|
||||
return w;
|
||||
};
|
||||
|
||||
const horizPad = 24;
|
||||
const appW = measureTextWidth(appLabel);
|
||||
const appsW = measureTextWidth(appsLabel);
|
||||
// Finder draws labels below icons; placeholders stay invisible in the PNG.
|
||||
const labelGapBelowSlot = -1;
|
||||
|
||||
const placeLabel = (el, slot, textWidth) => {
|
||||
if (!el || !slot) return;
|
||||
const width = textWidth + horizPad;
|
||||
el.style.width = `${width}px`;
|
||||
el.style.left = `${Math.round(slot.centerX)}px`;
|
||||
el.style.top = `${Math.round(slot.centerY + slot.height / 2 + labelGapBelowSlot)}px`;
|
||||
el.style.transform = "translateX(-50%)";
|
||||
};
|
||||
|
||||
placeLabel(appLabel, measured?.slots?.app, appW);
|
||||
placeLabel(appsLabel, measured?.slots?.applications, appsW);
|
||||
|
||||
const rectFor = (el) => {
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
const bg = getComputedStyle(el, "::before").backgroundColor;
|
||||
return { left: Math.round(r.left), top: Math.round(r.top), width: Math.round(r.width), height: Math.round(r.height), bg };
|
||||
};
|
||||
|
||||
return {
|
||||
labels: {
|
||||
app: { text: appLabel?.textContent || "", width: appW + horizPad, rect: rectFor(appLabel) },
|
||||
applications: { text: appsLabel?.textContent || "", width: appsW + horizPad, rect: rectFor(appsLabel) },
|
||||
},
|
||||
};
|
||||
},
|
||||
{ cfg: config, measured },
|
||||
);
|
||||
}
|
||||
|
||||
async function screenshotArtboard(page, outPath, deviceScaleFactor) {
|
||||
const dmg = page.locator("#dmg");
|
||||
await dmg.waitFor({ state: "visible" });
|
||||
await page.evaluate(async () => {
|
||||
if (document.fonts?.ready) await document.fonts.ready;
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
await dmg.screenshot({
|
||||
path: outPath,
|
||||
type: "png",
|
||||
omitBackground: false,
|
||||
scale: deviceScaleFactor === 2 ? "device" : "css",
|
||||
});
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const cfgText = await readFileSync(CONFIG_PATH, "utf8");
|
||||
return JSON.parse(cfgText);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function exportAtScale(browser, baseUrl, cfg, scale) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
deviceScaleFactor: scale,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl, { waitUntil: "networkidle" });
|
||||
await page.evaluate(async () => {
|
||||
if (document.fonts?.ready) await document.fonts.ready;
|
||||
});
|
||||
await applyConfig(page, cfg);
|
||||
const measured = await measureSlots(page);
|
||||
const labelMetrics = await applyLabelsAndMeasure(page, cfg, measured);
|
||||
const suffix = scale === 2 ? "@2x" : "";
|
||||
await screenshotArtboard(page, path.join(DIST, `dmg-background${suffix}.png`), scale);
|
||||
await context.close();
|
||||
return { measured, labelMetrics };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(DIST, { recursive: true });
|
||||
const { server, port } = await startStaticServer();
|
||||
const baseUrl = `http://127.0.0.1:${port}/`;
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const cfg = await loadConfig();
|
||||
|
||||
const { measured, labelMetrics } = await exportAtScale(browser, baseUrl, cfg, 1);
|
||||
console.log("label metrics:", JSON.stringify(labelMetrics));
|
||||
const positions = buildPositionsJson(measured);
|
||||
positions.labels = labelMetrics.labels;
|
||||
await writeFile(
|
||||
path.join(DIST, "icon-positions.json"),
|
||||
`${JSON.stringify(positions, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
console.log(`canvas ${positions.canvas.width}×${positions.canvas.height}`);
|
||||
|
||||
await exportAtScale(browser, baseUrl, cfg, 2);
|
||||
|
||||
console.log(`wrote ${path.join(DIST, "dmg-background.png")}`);
|
||||
console.log(`wrote ${path.join(DIST, "dmg-background@2x.png")}`);
|
||||
console.log(`wrote ${path.join(DIST, "icon-positions.json")}`);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FromChat — фон DMG</title>
|
||||
<!-- Montserrat for brand wordmark; Google Sans kept for the rest (local fonts) -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
Icon slots (#slot-app, #slot-applications) mark create-dmg / appdmg drop targets.
|
||||
Empty outlines only — Finder draws the real icons and labels.
|
||||
-->
|
||||
|
||||
<div id="dmg" role="img" aria-label="Фон установки FromChat (DMG)">
|
||||
<div class="window-chrome-bleed" aria-hidden="true"></div>
|
||||
<header class="brand">
|
||||
<div class="brand__logo-wrap">
|
||||
<div class="brand__logo" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="brand__wordmark">FromChat</div>
|
||||
<div class="brand__tagline">Перетащите в «Программы», чтобы установить</div>
|
||||
</header>
|
||||
|
||||
<div class="install">
|
||||
<div id="slot-app" class="slot" aria-hidden="true"></div>
|
||||
<div class="arrow" aria-hidden="true">
|
||||
<!-- Material Symbols Rounded arrow_forward (Google Fonts / fonts.gstatic) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" width="44" height="44" fill="currentColor">
|
||||
<path d="M686-450H190q-13 0-21.5-8.5T160-480q0-13 8.5-21.5T190-510h496L459-737q-9-9-9-21t9-21q9-9 21-9t21 9l278 278q5 5 7 10t2 11q0 6-2 11t-7 10L501-181q-9 9-21 9t-21-9q-9-9-9-21t9-21l227-227Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="slot-applications" class="slot" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<div class="slot__label" data-for="slot-app" aria-hidden="true">FromChat</div>
|
||||
<div class="slot__label" data-for="slot-applications" aria-hidden="true">Программы</div>
|
||||
|
||||
<p class="notice">
|
||||
Если macOS блокирует приложение, откройте
|
||||
<strong>Системные настройки → Конфиденциальность и безопасность</strong>
|
||||
и разрешите его.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "fromchat-dmg-background-tools",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fromchat-dmg-background-tools",
|
||||
"dependencies": {
|
||||
"playwright": "1.52.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
|
||||
"integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.52.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
|
||||
"integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "fromchat-dmg-background-tools",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"playwright": "1.52.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/* Fonts and logo are staged by :app:desktop:stageDmgBackground from composeResources. */
|
||||
|
||||
@font-face {
|
||||
font-family: "Google Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
src: url("assets/fonts/google_sans_regular.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Google Sans";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: block;
|
||||
src: url("assets/fonts/google_sans_medium.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Google Sans";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: block;
|
||||
src: url("assets/fonts/google_sans_semibold.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Google Sans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: block;
|
||||
src: url("assets/fonts/google_sans_bold.ttf") format("truetype");
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgb(21, 18, 24);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:root {
|
||||
--brand-gradient: linear-gradient(
|
||||
45deg,
|
||||
#9333ea,
|
||||
#6366f1,
|
||||
#3b82f6,
|
||||
#a855f7,
|
||||
#d946ef,
|
||||
#ec4899,
|
||||
#7e22ce
|
||||
);
|
||||
--brand-conic: conic-gradient(
|
||||
from 0deg,
|
||||
rgba(147, 51, 234, 0.5) 0%,
|
||||
rgba(99, 102, 241, 0.6) 12.5%,
|
||||
rgba(59, 130, 246, 0.55) 25%,
|
||||
rgba(168, 85, 247, 0.5) 37.5%,
|
||||
rgba(217, 70, 239, 0.6) 50%,
|
||||
rgba(236, 72, 153, 0.55) 62.5%,
|
||||
rgba(192, 132, 252, 0.5) 75%,
|
||||
rgba(126, 34, 206, 0.6) 87.5%,
|
||||
rgba(147, 51, 234, 0.5) 100%
|
||||
);
|
||||
--surface: rgb(21, 18, 24);
|
||||
--on-surface: rgba(230, 224, 233, 0.92);
|
||||
--on-surface-muted: rgba(230, 224, 233, 0.72);
|
||||
/* Extra strip at the bottom; Finder clips it so the visible area lines up with the title bar. */
|
||||
--window-chrome-bottom: 22px;
|
||||
--content-height: 380px;
|
||||
--slot-size: 96px;
|
||||
}
|
||||
|
||||
#dmg {
|
||||
position: relative;
|
||||
width: 540px;
|
||||
height: calc(var(--content-height) + var(--window-chrome-bottom));
|
||||
overflow: hidden;
|
||||
color: var(--on-surface);
|
||||
font-family: "Google Sans", system-ui, sans-serif;
|
||||
background-color: var(--surface);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Clipped by Finder at the bottom — keeps the designed content aligned under the title bar. */
|
||||
.window-chrome-bleed {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: var(--window-chrome-bottom);
|
||||
background-color: var(--surface);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.brand__logo-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow-x: clip;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand__logo-wrap::before {
|
||||
content: "";
|
||||
background: var(--brand-conic);
|
||||
position: absolute;
|
||||
width: 1200px;
|
||||
height: 1200px;
|
||||
bottom: -180px;
|
||||
opacity: 0.48;
|
||||
left: calc(50% - 600px);
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.brand__logo {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
background-image: url("assets/logo_square.png");
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 17px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.brand__wordmark {
|
||||
font-family: "Montserrat", "Google Sans", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1;
|
||||
background: var(--brand-gradient);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.brand__tagline {
|
||||
font-family: "Google Sans", sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.01em;
|
||||
color: rgba(230, 224, 233, 0.9);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.install {
|
||||
position: absolute;
|
||||
/* Vertically centered in the gap between brand block (~151px) and notice (~292px). */
|
||||
top: 174px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.slot {
|
||||
width: var(--slot-size);
|
||||
height: var(--slot-size);
|
||||
border: none;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
color: rgba(230, 224, 233, 0.88);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.arrow svg {
|
||||
display: block;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
bottom: calc(var(--window-chrome-bottom) + 16px);
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
font-family: "Google Sans", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--on-surface-muted);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.notice strong {
|
||||
font-weight: 600;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.slot__label {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: "Google Sans", system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--primary-pill, #7e22ce);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
min-height: 26px;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
|
||||
.slot__label::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
background: var(--primary-pill, #7e22ce);
|
||||
z-index: -1;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
# ProGuard specializes return types of @JvmMultifileClass actuals (VerifyError: Paragraph vs SkiaParagraph).
|
||||
# Upstream: https://github.com/JetBrains/compose-multiplatform/pull/5652 (CMP-10488)
|
||||
-keep,allowshrinking,allowobfuscation class **Kt__* { *; }
|
||||
|
||||
# Ktor ContentNegotiation JSON extension (ServiceLoader).
|
||||
-keep class io.ktor.serialization.kotlinx.json.KotlinxSerializationJsonExtensionProvider { *; }
|
||||
|
||||
# Ktor CIO client engine (ServiceLoader).
|
||||
-keep class io.ktor.client.engine.cio.CIOEngineContainer { *; }
|
||||
|
||||
# SQLite JDBC driver + JNI natives (ProGuard drops native methods otherwise).
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
-keep class org.sqlite.** { *; }
|
||||
|
||||
# BouncyCastle security providers (ServiceLoader java.security.Provider).
|
||||
-keep class org.bouncycastle.jce.provider.BouncyCastleProvider { *; }
|
||||
-keep class org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider { *; }
|
||||
|
||||
# Cryptography provider (ServiceLoader).
|
||||
-keep class dev.whyoleg.cryptography.providers.jdk.JdkCryptographyProviderContainer { *; }
|
||||
|
||||
# Coil fetchers/decoders registered via ServiceLoader (network + SVG).
|
||||
-keep class coil3.network.ktor3.internal.KtorNetworkFetcherServiceLoaderTarget { *; }
|
||||
-keep class coil3.svg.internal.SvgDecoderServiceLoaderTarget { *; }
|
||||
|
||||
# Compose Multiplatform generated resource accessors (logo, strings, fonts).
|
||||
-keep class ru.fromchat.Drawable*_commonMainKt { *; }
|
||||
-keep class ru.fromchat.String*_commonMainKt { *; }
|
||||
-keep class ru.fromchat.Font*_commonMainKt { *; }
|
||||
-keep class ru.fromchat.ActualResourceCollectorsKt { *; }
|
||||
-keep class ru.fromchat.Res { *; }
|
||||
-keep class ru.fromchat.Res$* { *; }
|
||||
|
||||
# App JSON models.
|
||||
-keep @kotlinx.serialization.Serializable class ru.fromchat.** { *; }
|
||||
-keepclassmembers @kotlinx.serialization.Serializable class ru.fromchat.** {
|
||||
<fields>;
|
||||
<init>(...);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
set "OUT_DIR=%~1"
|
||||
set "SRC=%~2"
|
||||
set "JAVA_HOME=%~3"
|
||||
set "ARCH=%~4"
|
||||
|
||||
if /I "%ARCH%"=="arm64" (
|
||||
set "VCVARS_NAME=vcvarsarm64.bat"
|
||||
) else (
|
||||
set "VCVARS_NAME=vcvars64.bat"
|
||||
)
|
||||
|
||||
set "VCVARS="
|
||||
if exist "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\%VCVARS_NAME%" (
|
||||
set "VCVARS=C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\%VCVARS_NAME%"
|
||||
)
|
||||
if not defined VCVARS if exist "C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\%VCVARS_NAME%" (
|
||||
set "VCVARS=C:\Program Files\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\%VCVARS_NAME%"
|
||||
)
|
||||
if not defined VCVARS if exist "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\%VCVARS_NAME%" (
|
||||
set "VCVARS=C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\%VCVARS_NAME%"
|
||||
)
|
||||
if not defined VCVARS if exist "C:\Program Files (x86)\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\%VCVARS_NAME%" (
|
||||
set "VCVARS=C:\Program Files (x86)\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\%VCVARS_NAME%"
|
||||
)
|
||||
if not defined VCVARS if exist "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\%VCVARS_NAME%" (
|
||||
set "VCVARS=C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\%VCVARS_NAME%"
|
||||
)
|
||||
if not defined VCVARS (
|
||||
for /f "delims=" %%i in ('where /r "C:\Program Files\Microsoft Visual Studio" %VCVARS_NAME% 2^>nul') do set "VCVARS=%%i"
|
||||
)
|
||||
if not defined VCVARS (
|
||||
echo compile-windows-native: %VCVARS_NAME% not found >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
call "%VCVARS%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
if not exist "%OUT_DIR%" mkdir "%OUT_DIR%"
|
||||
cd /d "%OUT_DIR%"
|
||||
|
||||
set "JNI_INCLUDE=%JAVA_HOME%\include"
|
||||
set "JNI_WIN32=%JNI_INCLUDE%\win32"
|
||||
|
||||
cl /nologo /LD /EHsc /std:c++17 /utf-8 ^
|
||||
/I"%JNI_INCLUDE%" /I"%JNI_WIN32%" ^
|
||||
"%SRC%" /Fe:fromchat_windows.dll ^
|
||||
/link windowsapp.lib user32.lib shell32.lib ole32.lib oleaut32.lib uxtheme.lib
|
||||
if errorlevel 1 (
|
||||
echo compile-windows-native: cl failed >&2
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "fromchat_windows.dll" (
|
||||
echo compile-windows-native: missing fromchat_windows.dll in %OUT_DIR% >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if exist "fromchat_windows.lib" del "fromchat_windows.lib"
|
||||
if exist "fromchat_windows.exp" del "fromchat_windows.exp"
|
||||
if exist "WindowsNativeBridge.obj" del "WindowsNativeBridge.obj"
|
||||
exit /b 0
|
||||
@@ -0,0 +1,91 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.api.local.db.store.MessageDatabasePaths
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object DatabaseLockGate {
|
||||
private val _blocked = MutableStateFlow(false)
|
||||
val blocked: StateFlow<Boolean> = _blocked.asStateFlow()
|
||||
|
||||
private val _processes = MutableStateFlow<List<LockingProcessInfo>>(emptyList())
|
||||
val processes: StateFlow<List<LockingProcessInfo>> = _processes.asStateFlow()
|
||||
|
||||
private val _runtimeSqliteBusy = MutableStateFlow(false)
|
||||
|
||||
private var monitorJob: Job? = null
|
||||
|
||||
fun onSqliteBusy(throwable: Throwable?): Boolean {
|
||||
Logger.w("DatabaseLockGate", "SQLite busy", throwable)
|
||||
_runtimeSqliteBusy.value = true
|
||||
return refreshBlockedState()
|
||||
}
|
||||
|
||||
fun startMonitoring(scope: CoroutineScope, onUnlocked: () -> Unit) {
|
||||
monitorJob?.cancel()
|
||||
monitorJob = scope.launch {
|
||||
var bootstrapDelivered = false
|
||||
while (isActive) {
|
||||
val blocked = refreshBlockedState()
|
||||
if (!blocked && !bootstrapDelivered) {
|
||||
bootstrapDelivered = true
|
||||
onUnlocked()
|
||||
}
|
||||
if (!blocked && bootstrapDelivered) return@launch
|
||||
delay(750.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun killProcess(pid: Long): Boolean = DatabaseLockingProcessResolver.killProcess(pid)
|
||||
|
||||
fun releaseRuntimeLock() {
|
||||
MessageDatabaseRuntimeLock.release()
|
||||
}
|
||||
|
||||
private fun refreshBlockedState(): Boolean {
|
||||
val externalProcesses = DatabaseLockingProcessResolver.findLockingProcesses(
|
||||
MessageDatabasePaths.lockProbeFiles(),
|
||||
)
|
||||
|
||||
if (!MessageDatabaseRuntimeLock.isHeld() && !MessageDatabaseRuntimeLock.tryAcquire()) {
|
||||
Logger.w(
|
||||
"DatabaseLockGate",
|
||||
"Instance lock held by another process; blockers=$externalProcesses",
|
||||
)
|
||||
_blocked.value = true
|
||||
_processes.value = externalProcesses
|
||||
return true
|
||||
}
|
||||
|
||||
if (externalProcesses.isNotEmpty()) {
|
||||
Logger.w(
|
||||
"DatabaseLockGate",
|
||||
"Database files in use by other process(es): $externalProcesses",
|
||||
)
|
||||
_blocked.value = true
|
||||
_processes.value = externalProcesses
|
||||
return true
|
||||
}
|
||||
|
||||
if (_runtimeSqliteBusy.value) {
|
||||
Logger.i(
|
||||
"DatabaseLockGate",
|
||||
"SQLite busy with no external locker — treating as in-process contention, not blocking",
|
||||
)
|
||||
_runtimeSqliteBusy.value = false
|
||||
}
|
||||
|
||||
_blocked.value = false
|
||||
_processes.value = emptyList()
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
||||
data class LockingProcessInfo(
|
||||
val pid: Long,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val executablePath: String?,
|
||||
val icon: ImageBitmap?,
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.desktop_database_locked_kill
|
||||
import ru.fromchat.desktop_database_locked_message
|
||||
import ru.fromchat.desktop_database_locked_pid
|
||||
import ru.fromchat.desktop_database_locked_title
|
||||
import ru.fromchat.desktop_database_locked_unknown_hint
|
||||
import ru.fromchat.desktop_database_locked_waiting
|
||||
import ru.fromchat.desktop_quit
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveIconFrame
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.components.TextCta
|
||||
import java.awt.image.BufferedImage
|
||||
|
||||
@Composable
|
||||
fun DatabaseLockWindow(
|
||||
appName: String,
|
||||
windowIcon: Painter,
|
||||
dockIconImage: BufferedImage?,
|
||||
processes: List<LockingProcessInfo>,
|
||||
onKillProcess: (Long) -> Unit,
|
||||
onQuit: () -> Unit,
|
||||
) {
|
||||
val windows = remember { isWindowsOs() }
|
||||
val windowState = rememberWindowState(width = 420.dp, height = 480.dp)
|
||||
Window(
|
||||
onCloseRequest = onQuit,
|
||||
title = appName,
|
||||
state = windowState,
|
||||
icon = windowIcon,
|
||||
undecorated = windows,
|
||||
resizable = false,
|
||||
) {
|
||||
DesktopRootSurface(
|
||||
appName = appName,
|
||||
windowIcon = windowIcon,
|
||||
dockIconImage = dockIconImage,
|
||||
windows = windows,
|
||||
windowState = windowState,
|
||||
onCloseRequest = onQuit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
top = if (windows) WindowsTitleBarHeight + 24.dp else 24.dp,
|
||||
start = 32.dp,
|
||||
end = 32.dp,
|
||||
bottom = 24.dp,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DatabaseLockContent(
|
||||
processes = processes,
|
||||
onKillProcess = onKillProcess,
|
||||
onQuit = onQuit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun DatabaseLockContent(
|
||||
processes: List<LockingProcessInfo>,
|
||||
onKillProcess: (Long) -> Unit,
|
||||
onQuit: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
ExpressiveIconFrame(
|
||||
icon = Icons.Default.Warning,
|
||||
containerSize = 56.dp,
|
||||
iconSize = 28.dp,
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
materialPolygon = MaterialShapes.SoftBurst,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_database_locked_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_database_locked_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
if (processes.isNotEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
processes.forEach { process ->
|
||||
LockingProcessRow(
|
||||
process = process,
|
||||
onKill = { onKillProcess(process.pid) },
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_database_locked_unknown_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_database_locked_waiting),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
TextCta(
|
||||
onClick = onQuit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_quit),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockingProcessRow(
|
||||
process: LockingProcessInfo,
|
||||
onKill: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (process.icon != null) {
|
||||
Image(
|
||||
bitmap = process.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = process.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
process.description?.let { description ->
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (process.pid > 0L) {
|
||||
Text(
|
||||
text = stringResource(Res.string.desktop_database_locked_pid, process.pid.toInt()),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (process.pid > 0L) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
ActionButton(onClick = onKill) {
|
||||
Text(stringResource(Res.string.desktop_database_locked_kill))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import java.io.File
|
||||
import java.lang.ProcessHandle
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object DatabaseLockingProcessResolver {
|
||||
fun findLockingProcesses(files: List<File>): List<LockingProcessInfo> {
|
||||
val probeFiles = files
|
||||
.map { file -> runCatching { file.canonicalFile }.getOrDefault(file) }
|
||||
.distinctBy { it.absolutePath }
|
||||
val discovered = when {
|
||||
isWindowsOs() -> WindowsRestartManager.findLockingProcesses(probeFiles)
|
||||
isMacOs() -> findWithLsof(probeFiles.filter { it.exists() })
|
||||
isLinuxOs() -> findWithFuser(probeFiles.filter { it.exists() })
|
||||
else -> emptyList()
|
||||
}
|
||||
return discovered
|
||||
.distinctBy { it.pid }
|
||||
.sortedBy { it.name.lowercase() }
|
||||
}
|
||||
|
||||
fun killProcess(pid: Long): Boolean = ProcessExecutableResolver.killProcess(pid)
|
||||
|
||||
private fun findWithLsof(files: List<File>): List<LockingProcessInfo> {
|
||||
val pids = linkedSetOf<Long>()
|
||||
files.forEach { file ->
|
||||
val process = runCatching {
|
||||
ProcessBuilder("lsof", "-t", file.absolutePath)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
}.getOrNull() ?: return@forEach
|
||||
process.waitFor(3, TimeUnit.SECONDS)
|
||||
process.inputStream.bufferedReader().readLines()
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.forEach { pids += it }
|
||||
}
|
||||
return pids.mapNotNull { pidToLockingProcess(it) }
|
||||
}
|
||||
|
||||
private fun findWithFuser(files: List<File>): List<LockingProcessInfo> {
|
||||
val pids = linkedSetOf<Long>()
|
||||
files.forEach { file ->
|
||||
val process = runCatching {
|
||||
ProcessBuilder("fuser", file.absolutePath)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
}.getOrNull() ?: return@forEach
|
||||
process.waitFor(3, TimeUnit.SECONDS)
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
output.split(Regex("\\s+"))
|
||||
.mapNotNull { token -> token.trim().toLongOrNull() }
|
||||
.forEach { pids += it }
|
||||
}
|
||||
return pids.mapNotNull { pidToLockingProcess(it) }
|
||||
}
|
||||
|
||||
private fun pidToLockingProcess(pid: Long): LockingProcessInfo? {
|
||||
if (pid == ProcessHandle.current().pid()) return null
|
||||
val handle = ProcessHandle.of(pid)
|
||||
if (handle.isEmpty) return null
|
||||
val info = handle.get().info()
|
||||
val command = info.command().orElse("")
|
||||
val executable = ProcessExecutableResolver.executablePathForPid(pid)
|
||||
val name = executable?.let { File(it).name }
|
||||
?: command.substringBefore(' ').takeIf { it.isNotBlank() }
|
||||
?: "PID $pid"
|
||||
return LockingProcessInfo(
|
||||
pid = pid,
|
||||
name = name,
|
||||
description = command.ifBlank { null },
|
||||
executablePath = executable,
|
||||
icon = ProcessExecutableResolver.iconForExecutable(executable),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.CloudDone
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.CloudSync
|
||||
import androidx.compose.material.icons.filled.DesktopWindows
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import java.awt.AWTEvent
|
||||
import java.awt.Point
|
||||
import java.awt.SystemTray
|
||||
import java.awt.Toolkit
|
||||
import java.awt.TrayIcon
|
||||
import java.awt.event.AWTEventListener
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.awt.event.WindowEvent
|
||||
import java.awt.image.BufferedImage
|
||||
import javax.swing.JFrame
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.math.roundToInt
|
||||
import ru.fromchat.ui.FromChatTheme
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.isInsideWindow
|
||||
|
||||
internal enum class TrayConnectionStatus {
|
||||
Connected,
|
||||
Connecting,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopTrayHost(
|
||||
trayImage: BufferedImage,
|
||||
tooltip: String,
|
||||
statusLabel: String,
|
||||
connectionStatus: TrayConnectionStatus,
|
||||
showLabel: String,
|
||||
aboutLabel: String,
|
||||
quitLabel: String,
|
||||
onShow: () -> Unit,
|
||||
onAbout: () -> Unit,
|
||||
onQuit: () -> Unit,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var menuAnchor by remember { mutableStateOf<Point?>(null) }
|
||||
val closeMenu = rememberUpdatedState { menuOpen = false }
|
||||
val onShowState = rememberUpdatedState(onShow)
|
||||
|
||||
DisposableEffect(trayImage, tooltip) {
|
||||
if (!SystemTray.isSupported()) {
|
||||
onDispose {}
|
||||
} else {
|
||||
val trayIcon = TrayIcon(trayImage, tooltip)
|
||||
trayIcon.isImageAutoSize = true
|
||||
fun openMenu(event: MouseEvent) {
|
||||
val screen = event.locationOnScreen
|
||||
SwingUtilities.invokeLater {
|
||||
menuAnchor = screen
|
||||
menuOpen = true
|
||||
}
|
||||
}
|
||||
val listener = object : MouseAdapter() {
|
||||
override fun mouseClicked(event: MouseEvent) {
|
||||
if (MouseEvent.BUTTON1 == event.button && event.clickCount == 1) {
|
||||
SwingUtilities.invokeLater { onShowState.value() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun mousePressed(event: MouseEvent) {
|
||||
if (event.isPopupTrigger) {
|
||||
event.consume()
|
||||
}
|
||||
}
|
||||
|
||||
override fun mouseReleased(event: MouseEvent) {
|
||||
if (event.isPopupTrigger) {
|
||||
openMenu(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
trayIcon.addMouseListener(listener)
|
||||
val tray = SystemTray.getSystemTray()
|
||||
runCatching { tray.add(trayIcon) }
|
||||
onDispose {
|
||||
trayIcon.removeMouseListener(listener)
|
||||
runCatching { tray.remove(trayIcon) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (menuOpen) {
|
||||
val density = LocalDensity.current
|
||||
var contentSizePx by remember { mutableStateOf(IntSize.Zero) }
|
||||
var showWindow by remember { mutableStateOf(false) }
|
||||
val windowState = rememberWindowState(
|
||||
width = 220.dp,
|
||||
height = 260.dp,
|
||||
position = WindowPosition((-20_000).dp, (-20_000).dp),
|
||||
)
|
||||
|
||||
Window(
|
||||
onCloseRequest = { menuOpen = false },
|
||||
state = windowState,
|
||||
title = "",
|
||||
undecorated = true,
|
||||
transparent = false,
|
||||
resizable = false,
|
||||
alwaysOnTop = true,
|
||||
focusable = true,
|
||||
onPreviewKeyEvent = { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) {
|
||||
menuOpen = false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
) {
|
||||
val awtWindow = window
|
||||
|
||||
SideEffect {
|
||||
if (!showWindow) {
|
||||
awtWindow.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(awtWindow) {
|
||||
showWindow = false
|
||||
awtWindow.isVisible = false
|
||||
awtWindow.setLocation(-20_000, -20_000)
|
||||
|
||||
val dismiss = { SwingUtilities.invokeLater { closeMenu.value() } }
|
||||
val toolkit = Toolkit.getDefaultToolkit()
|
||||
var armed = false
|
||||
val armTimer = javax.swing.Timer(200) { armed = true }.apply {
|
||||
isRepeats = false
|
||||
start()
|
||||
}
|
||||
val dismissListener = AWTEventListener { event ->
|
||||
when (event.id) {
|
||||
MouseEvent.MOUSE_RELEASED -> armed = true
|
||||
MouseEvent.MOUSE_PRESSED -> {
|
||||
if (!armed) return@AWTEventListener
|
||||
val mouse = event as MouseEvent
|
||||
if (mouse.button != MouseEvent.BUTTON1) return@AWTEventListener
|
||||
if (mouse.isInsideWindow(awtWindow)) return@AWTEventListener
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
var focusDismissArmed = false
|
||||
val focusArmTimer = javax.swing.Timer(250) { focusDismissArmed = true }.apply {
|
||||
isRepeats = false
|
||||
start()
|
||||
}
|
||||
val focusListener = object : java.awt.event.WindowFocusListener {
|
||||
override fun windowGainedFocus(event: WindowEvent) = Unit
|
||||
|
||||
override fun windowLostFocus(event: WindowEvent) {
|
||||
if (focusDismissArmed) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
toolkit.addAWTEventListener(dismissListener, AWTEvent.MOUSE_EVENT_MASK)
|
||||
awtWindow.addWindowFocusListener(focusListener)
|
||||
onDispose {
|
||||
armTimer.stop()
|
||||
focusArmTimer.stop()
|
||||
toolkit.removeAWTEventListener(dismissListener)
|
||||
awtWindow.removeWindowFocusListener(focusListener)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(contentSizePx, menuAnchor) {
|
||||
val anchor = menuAnchor
|
||||
if (anchor == null || contentSizePx.width < 80 || contentSizePx.height < 40) {
|
||||
showWindow = false
|
||||
awtWindow.isVisible = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val scale = density.density
|
||||
val awtWidth = (contentSizePx.width / scale).roundToInt().coerceAtLeast(1)
|
||||
val awtHeight = (contentSizePx.height / scale).roundToInt().coerceAtLeast(1)
|
||||
val screen = awtWindow.graphicsConfiguration.bounds
|
||||
val x = (anchor.x - awtWidth).coerceIn(
|
||||
screen.x,
|
||||
(screen.x + screen.width - awtWidth).coerceAtLeast(screen.x),
|
||||
)
|
||||
val y = (anchor.y - awtHeight).coerceIn(
|
||||
screen.y,
|
||||
(screen.y + screen.height - awtHeight).coerceAtLeast(screen.y),
|
||||
)
|
||||
awtWindow.setSize(awtWidth, awtHeight)
|
||||
awtWindow.setLocation(x, y)
|
||||
with(density) {
|
||||
windowState.position = WindowPosition(
|
||||
x = (x / density.density).dp,
|
||||
y = (y / density.density).dp,
|
||||
)
|
||||
windowState.size = DpSize(
|
||||
width = contentSizePx.width.toDp(),
|
||||
height = contentSizePx.height.toDp(),
|
||||
)
|
||||
}
|
||||
excludeFromWindowsTaskbar(awtWindow)
|
||||
showWindow = true
|
||||
awtWindow.isVisible = true
|
||||
awtWindow.toFront()
|
||||
awtWindow.requestFocus()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.wrapContentSize(unbounded = true)
|
||||
.onSizeChanged { size ->
|
||||
if (size.width > 0 && size.height > 0) {
|
||||
contentSizePx = size
|
||||
}
|
||||
},
|
||||
) {
|
||||
FromChatTheme(darkTheme = desktopAppDarkTheme(), dynamicColor = false) {
|
||||
val surfaceColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
SideEffect {
|
||||
val awtColor = java.awt.Color(
|
||||
surfaceColor.red,
|
||||
surfaceColor.green,
|
||||
surfaceColor.blue,
|
||||
surfaceColor.alpha,
|
||||
)
|
||||
awtWindow.background = awtColor
|
||||
(awtWindow as? JFrame)?.contentPane?.background = awtColor
|
||||
}
|
||||
DesktopTrayMenuContent(
|
||||
statusLabel = statusLabel,
|
||||
connectionStatus = connectionStatus,
|
||||
showLabel = showLabel,
|
||||
aboutLabel = aboutLabel,
|
||||
quitLabel = quitLabel,
|
||||
onShow = {
|
||||
menuOpen = false
|
||||
onShow()
|
||||
},
|
||||
onAbout = {
|
||||
menuOpen = false
|
||||
onAbout()
|
||||
},
|
||||
onQuit = {
|
||||
menuOpen = false
|
||||
onQuit()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopTrayMenuContent(
|
||||
statusLabel: String,
|
||||
connectionStatus: TrayConnectionStatus,
|
||||
showLabel: String,
|
||||
aboutLabel: String,
|
||||
quitLabel: String,
|
||||
onShow: () -> Unit,
|
||||
onAbout: () -> Unit,
|
||||
onQuit: () -> Unit,
|
||||
) {
|
||||
val statusIcon = when (connectionStatus) {
|
||||
TrayConnectionStatus.Connected -> Icons.Filled.CloudDone
|
||||
TrayConnectionStatus.Connecting -> Icons.Filled.CloudSync
|
||||
TrayConnectionStatus.Disconnected -> Icons.Filled.CloudOff
|
||||
}
|
||||
val statusTint = when (connectionStatus) {
|
||||
TrayConnectionStatus.Connected -> MaterialTheme.colorScheme.primary
|
||||
TrayConnectionStatus.Connecting -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
TrayConnectionStatus.Disconnected -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(IntrinsicSize.Max)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
.padding(vertical = 2.dp),
|
||||
) {
|
||||
DesktopTrayMenuRow(
|
||||
text = statusLabel,
|
||||
icon = statusIcon,
|
||||
iconTint = statusTint,
|
||||
enabled = false,
|
||||
onClick = {},
|
||||
)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
DesktopTrayMenuRow(
|
||||
text = showLabel,
|
||||
icon = Icons.Filled.DesktopWindows,
|
||||
onClick = onShow,
|
||||
)
|
||||
DesktopTrayMenuRow(
|
||||
text = aboutLabel,
|
||||
icon = Icons.Filled.Info,
|
||||
onClick = onAbout,
|
||||
)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
DesktopTrayMenuRow(
|
||||
text = quitLabel,
|
||||
icon = Icons.AutoMirrored.Filled.Logout,
|
||||
textColor = MaterialTheme.colorScheme.error,
|
||||
iconTint = MaterialTheme.colorScheme.error,
|
||||
onClick = onQuit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopTrayMenuRow(
|
||||
text: String,
|
||||
icon: ImageVector,
|
||||
enabled: Boolean = true,
|
||||
textColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
iconTint: Color = textColor,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val hovered by interactionSource.collectIsHoveredAsState()
|
||||
val resolvedTextColor = when {
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else -> textColor
|
||||
}
|
||||
val resolvedIconTint = when {
|
||||
!enabled -> iconTint.copy(alpha = 0.7f)
|
||||
else -> iconTint
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
if (enabled && hovered) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
)
|
||||
.hoverable(interactionSource, enabled = enabled)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = resolvedIconTint,
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = resolvedTextColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import ru.fromchat.config.Settings
|
||||
import ru.fromchat.ui.Theme
|
||||
import javax.swing.JRootPane
|
||||
|
||||
internal fun isMacOs(): Boolean =
|
||||
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
|
||||
|
||||
internal fun isWindowsOs(): Boolean =
|
||||
System.getProperty("os.name").orEmpty().lowercase().contains("win")
|
||||
|
||||
internal fun isLinuxOs(): Boolean =
|
||||
System.getProperty("os.name").orEmpty().lowercase().contains("linux")
|
||||
|
||||
internal fun isWindowsArm64(): Boolean {
|
||||
if (!isWindowsOs()) return false
|
||||
val arch = System.getProperty("os.arch").orEmpty().lowercase()
|
||||
return arch.contains("aarch64") || arch.contains("arm64")
|
||||
}
|
||||
|
||||
internal fun desktopAppDarkTheme(): Boolean =
|
||||
when (runCatching { Settings.theme }.getOrDefault(Theme.AsSystem)) {
|
||||
Theme.Dark -> true
|
||||
Theme.Light -> false
|
||||
Theme.AsSystem -> desktopSystemDarkTheme()
|
||||
}
|
||||
|
||||
internal fun desktopSystemDarkTheme(): Boolean = runCatching {
|
||||
when {
|
||||
isMacOs() ->
|
||||
ProcessBuilder("defaults", "read", "-g", "AppleInterfaceStyle")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
.inputStream
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
.trim()
|
||||
.equals("Dark", ignoreCase = true)
|
||||
isWindowsOs() -> isWindowsAppsDarkTheme()
|
||||
else -> false
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun isWindowsAppsDarkTheme(): Boolean {
|
||||
val process = ProcessBuilder(
|
||||
"reg",
|
||||
"query",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
|
||||
"/v",
|
||||
"AppsUseLightTheme",
|
||||
)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
return when {
|
||||
"0x0" in output -> true
|
||||
"0x1" in output -> false
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/** Enables drawing under the system title bar on macOS. */
|
||||
internal fun applyDesktopEdgeToEdgeChrome(rootPane: JRootPane) {
|
||||
if (!isMacOs()) return
|
||||
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
|
||||
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
|
||||
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.FrameWindowScope
|
||||
import androidx.compose.ui.window.WindowExceptionHandler
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.awt.ComposeWindow
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.api.local.db.isSqliteBusy
|
||||
import ru.fromchat.ui.FromChatTheme
|
||||
import ru.fromchat.ui.LocalExtraStatusBarTop
|
||||
import ru.fromchat.ui.getColorScheme
|
||||
import java.awt.image.BufferedImage
|
||||
|
||||
internal fun desktopThemeBackgroundCompose(): Color =
|
||||
if (desktopAppDarkTheme()) Color(0xFF1C1B1F) else Color(0xFFFFFBFE)
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
internal fun installDesktopWindowExceptionHandler(window: ComposeWindow) {
|
||||
window.exceptionHandler = WindowExceptionHandler { throwable ->
|
||||
if (isSqliteBusy(throwable)) {
|
||||
if (!DatabaseLockGate.onSqliteBusy(throwable)) {
|
||||
Logger.w("DesktopWindow", "Suppressed SQLITE_BUSY in composition", throwable)
|
||||
}
|
||||
} else {
|
||||
Logger.e("DesktopWindow", "uncaught in composition", throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun FrameWindowScope.DesktopRootSurface(
|
||||
appName: String,
|
||||
windowIcon: Painter,
|
||||
dockIconImage: BufferedImage?,
|
||||
windows: Boolean,
|
||||
windowState: WindowState,
|
||||
onCloseRequest: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val windowChrome = desktopThemeBackgroundCompose()
|
||||
|
||||
LaunchedEffect(window, appName, windowChrome) {
|
||||
window.title = appName
|
||||
windowChrome.toAwtColor().also {
|
||||
window.background = it
|
||||
window.contentPane.background = it
|
||||
if (windows) {
|
||||
updateWindowsNativeCaptionBackground(window, it)
|
||||
}
|
||||
}
|
||||
|
||||
if (windows) {
|
||||
installWindowsNativeCaptionChrome(window)
|
||||
applyWindowsRoundedCorners(window)
|
||||
}
|
||||
applyDesktopEdgeToEdgeChrome(window.rootPane)
|
||||
dockIconImage?.let { image ->
|
||||
window.iconImages = listOf(image)
|
||||
applyDockIcon(image)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(window) {
|
||||
installDesktopWindowExceptionHandler(window)
|
||||
onDispose {}
|
||||
}
|
||||
|
||||
FromChatTheme(darkTheme = desktopAppDarkTheme()) {
|
||||
CompositionLocalProvider(
|
||||
LocalExtraStatusBarTop provides when {
|
||||
windows -> WindowsTitleBarHeight
|
||||
else -> 0.dp
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(windowChrome),
|
||||
) {
|
||||
content()
|
||||
if (windows) {
|
||||
MaterialTheme(colorScheme = getColorScheme(desktopAppDarkTheme(), dynamicColor = false)) {
|
||||
WindowsDesktopTitleBar(
|
||||
title = appName,
|
||||
windowIcon = windowIcon,
|
||||
window = window,
|
||||
windowState = windowState,
|
||||
onCloseRequest = onCloseRequest,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.zIndex(10_000f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Color.toAwtColor() =
|
||||
java.awt.Color(red, green, blue, alpha)
|
||||
@@ -0,0 +1,53 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
/**
|
||||
* Desktop main-window size and position prefs.
|
||||
*
|
||||
* Stored in the same JVM prefs node as PlatformSettings (`ru.fromchat.settings`):
|
||||
* - `desktop_window_width` / `desktop_window_height` (float dp)
|
||||
* - `desktop_window_x` / `desktop_window_y` (float dp, absolute; absent → platform default)
|
||||
*
|
||||
* List–detail left pane width uses the sibling key `desktop_list_pane_width`
|
||||
* (see [ru.fromchat.ui.main.ConversationListDetailShell]).
|
||||
*/
|
||||
internal object DesktopWindowPrefs {
|
||||
private const val WIDTH_KEY = "desktop_window_width"
|
||||
private const val HEIGHT_KEY = "desktop_window_height"
|
||||
private const val X_KEY = "desktop_window_x"
|
||||
private const val Y_KEY = "desktop_window_y"
|
||||
|
||||
private val prefs: Preferences =
|
||||
Preferences.userRoot().node("ru.fromchat.settings")
|
||||
|
||||
fun loadSize(): DpSize {
|
||||
val width = prefs.getFloat(WIDTH_KEY, 0f)
|
||||
val height = prefs.getFloat(HEIGHT_KEY, 0f)
|
||||
return if (width <= 0f || height <= 0f) {
|
||||
DpSize(800.dp, 600.dp)
|
||||
} else {
|
||||
DpSize(width.dp, height.dp)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPosition(): WindowPosition {
|
||||
if (prefs.get(X_KEY, null) == null || prefs.get(Y_KEY, null) == null) {
|
||||
return WindowPosition.PlatformDefault
|
||||
}
|
||||
return WindowPosition(prefs.getFloat(X_KEY, 0f).dp, prefs.getFloat(Y_KEY, 0f).dp)
|
||||
}
|
||||
|
||||
fun save(size: DpSize, position: WindowPosition) {
|
||||
prefs.putFloat(WIDTH_KEY, size.width.value)
|
||||
prefs.putFloat(HEIGHT_KEY, size.height.value)
|
||||
if (position is WindowPosition.Absolute) {
|
||||
prefs.putFloat(X_KEY, position.x.value)
|
||||
prefs.putFloat(Y_KEY, position.y.value)
|
||||
}
|
||||
runCatching { prefs.flush() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
/**
|
||||
* macOS close-to-background policy notes (implemented in [MainKt] / tray close handler).
|
||||
*
|
||||
* ## Window close → background (not quit)
|
||||
* The red traffic-light / window close hides the main window and keeps the JVM process
|
||||
* plus menu-bar tray alive (close-to-tray). Quit is only via tray Quit, File → Quit, or ⌘Q.
|
||||
* Dock icon stays (NSApplicationActivationPolicyRegular) so macOS can show the Dock
|
||||
* "Running in Background" indicator when no windows are visible.
|
||||
*
|
||||
* ## No Login Item / SMAppService
|
||||
* We intentionally do **not** register `SMAppService.mainAppService` (Open at Login /
|
||||
* Login Items & Extensions). Close-to-tray does not require that registration; the process
|
||||
* simply stays alive with a menu-bar tray while the window is hidden.
|
||||
*/
|
||||
internal object MacBackgroundLifecycle {
|
||||
const val LOG_TAG = "MacBackground"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import ru.fromchat.api.local.db.store.MessageDatabasePaths
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.channels.FileLock
|
||||
|
||||
/**
|
||||
* Holds an exclusive lock on a sidecar file for the lifetime of this process so only one
|
||||
* desktop instance can use the message database at a time.
|
||||
*/
|
||||
internal object MessageDatabaseRuntimeLock {
|
||||
private var lockHolder: RandomAccessFile? = null
|
||||
private var fileLock: FileLock? = null
|
||||
|
||||
fun isHeld(): Boolean = fileLock?.isValid == true
|
||||
|
||||
fun tryAcquire(): Boolean {
|
||||
if (isHeld()) return true
|
||||
val lockFile = instanceLockFile()
|
||||
lockFile.parentFile?.mkdirs()
|
||||
return runCatching {
|
||||
val raf = RandomAccessFile(lockFile, "rw")
|
||||
val lock = raf.channel.tryLock()
|
||||
if (lock == null) {
|
||||
raf.close()
|
||||
false
|
||||
} else {
|
||||
lockHolder = raf
|
||||
fileLock = lock
|
||||
true
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
runCatching { fileLock?.release() }
|
||||
runCatching { lockHolder?.close() }
|
||||
fileLock = null
|
||||
lockHolder = null
|
||||
}
|
||||
|
||||
private fun instanceLockFile(): File {
|
||||
val dbDir = MessageDatabasePaths.databaseFile().parentFile
|
||||
return File(dbDir, "message_database.instance.lock")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.lang.ProcessHandle
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.ImageIcon
|
||||
import javax.swing.filechooser.FileSystemView
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
|
||||
internal object ProcessExecutableResolver {
|
||||
fun executablePathForPid(pid: Long): String? {
|
||||
if (pid <= 0L) return null
|
||||
return runCatching {
|
||||
ProcessHandle.of(pid).flatMap { handle ->
|
||||
handle.info().command().map { command ->
|
||||
extractExecutablePath(command)
|
||||
}
|
||||
}.orElse(null)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun commandLineForPid(pid: Long): String? {
|
||||
if (pid <= 0L) return null
|
||||
return runCatching {
|
||||
ProcessHandle.of(pid).flatMap { it.info().command() }.orElse(null)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun iconForExecutable(path: String?): ImageBitmap? {
|
||||
if (path.isNullOrBlank()) return null
|
||||
val file = File(path)
|
||||
if (!file.exists()) return null
|
||||
val awtIcon = runCatching {
|
||||
val icon = FileSystemView.getFileSystemView().getSystemIcon(file)
|
||||
when (icon) {
|
||||
is ImageIcon -> icon.image as? BufferedImage
|
||||
else -> null
|
||||
}
|
||||
}.getOrNull() ?: return null
|
||||
return bufferedImageToImageBitmap(awtIcon)
|
||||
}
|
||||
|
||||
private fun bufferedImageToImageBitmap(image: BufferedImage): ImageBitmap {
|
||||
val bytes = ByteArrayOutputStream().use { out ->
|
||||
ImageIO.write(image, "png", out)
|
||||
out.toByteArray()
|
||||
}
|
||||
return SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
|
||||
}
|
||||
|
||||
private fun extractExecutablePath(command: String): String? {
|
||||
val trimmed = command.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
if (trimmed.first() == '"') {
|
||||
val end = trimmed.indexOf('"', startIndex = 1)
|
||||
if (end > 1) return trimmed.substring(1, end)
|
||||
}
|
||||
return trimmed.substringBefore(' ').takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun killProcess(pid: Long): Boolean {
|
||||
if (pid <= 0L) return false
|
||||
val handle = ProcessHandle.of(pid)
|
||||
if (handle.isEmpty) return false
|
||||
return runCatching {
|
||||
handle.get().destroyForcibly()
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import com.sun.jna.WString
|
||||
import com.sun.jna.platform.win32.Shell32
|
||||
|
||||
/** Taskbar / jump-list identity for the desktop app (must run before first window). */
|
||||
internal fun setWindowsDesktopAppUserModelId(registrationId: String) {
|
||||
if (!isWindowsOs()) return
|
||||
val id = when (registrationId) {
|
||||
"FromChat Beta" -> "denis0001-dev.FromChat.Beta"
|
||||
else -> "denis0001-dev.FromChat"
|
||||
}
|
||||
Shell32.INSTANCE.SetCurrentProcessExplicitAppUserModelID(WString(id))
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.window.WindowDraggableArea
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import androidx.compose.ui.window.WindowScope
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import java.awt.Window
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
/** Height of the custom Windows title bar; keep in sync with [ru.fromchat.ui.LocalExtraStatusBarTop]. */
|
||||
val WindowsTitleBarHeight = 32.dp
|
||||
|
||||
private val TitleBarHoverAlpha = 0.28f
|
||||
private val TitleBarPressAlpha = 0.36f
|
||||
private val TitleBarCloseHoverAlpha = 1f
|
||||
|
||||
@Composable
|
||||
fun WindowScope.WindowsDesktopTitleBar(
|
||||
title: String,
|
||||
windowIcon: Painter,
|
||||
window: Window,
|
||||
windowState: WindowState,
|
||||
onCloseRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
window.syncWindowsPlacementFromNative(windowState)
|
||||
val windowActive = rememberWindowsFrameActive(window)
|
||||
val isMaximized = windowState.placement == WindowPlacement.Maximized
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val titleColor =
|
||||
if (windowActive) {
|
||||
Color.White
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.63f)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(WindowsTitleBarHeight),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
WindowDraggableArea(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(start = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = windowIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
alpha = if (windowActive) 1f else 0.63f,
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = titleColor,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
TitleBarWindowButton(
|
||||
onClick = { window.windowsMinimize() },
|
||||
windowActive = windowActive,
|
||||
isClose = false,
|
||||
) {
|
||||
TitleBarRemoveIcon(tint = it)
|
||||
}
|
||||
TitleBarWindowButton(
|
||||
onClick = { window.windowsToggleMaximize(windowState) },
|
||||
windowActive = windowActive,
|
||||
isClose = false,
|
||||
) {
|
||||
if (isMaximized) {
|
||||
TitleBarRestoreIcon(tint = it)
|
||||
} else {
|
||||
TitleBarMaximizeIcon(tint = it)
|
||||
}
|
||||
}
|
||||
TitleBarWindowButton(
|
||||
onClick = onCloseRequest,
|
||||
windowActive = windowActive,
|
||||
isClose = true,
|
||||
) {
|
||||
TitleBarCloseIcon(tint = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TitleBarWindowButton(
|
||||
onClick: () -> Unit,
|
||||
windowActive: Boolean,
|
||||
isClose: Boolean,
|
||||
icon: @Composable (Color) -> Unit,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val hovered by interactionSource.collectIsHoveredAsState()
|
||||
val pressed by interactionSource.collectIsPressedAsState()
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val iconTint = when {
|
||||
isClose && (hovered || pressed) -> scheme.onError
|
||||
windowActive -> Color.White
|
||||
else -> Color.White.copy(alpha = 0.63f)
|
||||
}
|
||||
val hoverAlpha = when {
|
||||
isClose && (hovered || pressed) -> TitleBarCloseHoverAlpha
|
||||
pressed -> TitleBarPressAlpha
|
||||
hovered -> TitleBarHoverAlpha
|
||||
else -> 0f
|
||||
}
|
||||
val hoverColor =
|
||||
if (isClose && (hovered || pressed)) {
|
||||
scheme.error
|
||||
} else {
|
||||
scheme.onSurface
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(46.dp)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(0.dp))
|
||||
.hoverable(interactionSource)
|
||||
.background(hoverColor.copy(alpha = hoverAlpha))
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
icon(iconTint)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TitleBarRemoveIcon(tint: Color) {
|
||||
Canvas(Modifier.size(16.dp)) {
|
||||
val y = size.height / 2f
|
||||
val pad = size.width * 0.22f
|
||||
drawLine(
|
||||
color = tint,
|
||||
start = Offset(pad, y),
|
||||
end = Offset(size.width - pad, y),
|
||||
strokeWidth = size.height * 0.06f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TitleBarCloseIcon(tint: Color) {
|
||||
Canvas(Modifier.size(16.dp)) {
|
||||
val pad = size.width * 0.28f
|
||||
val stroke = size.height * 0.06f
|
||||
drawLine(color = tint, start = Offset(pad, pad), end = Offset(size.width - pad, size.height - pad), strokeWidth = stroke)
|
||||
drawLine(color = tint, start = Offset(size.width - pad, pad), end = Offset(pad, size.height - pad), strokeWidth = stroke)
|
||||
}
|
||||
}
|
||||
|
||||
/** Material `select_window_2` — rounded maximize tile. */
|
||||
@Composable
|
||||
private fun TitleBarMaximizeIcon(tint: Color) {
|
||||
Canvas(Modifier.size(16.dp)) {
|
||||
val inset = size.width * 0.24f
|
||||
val stroke = size.height * 0.06f
|
||||
val corner = size.width * 0.12f
|
||||
drawRoundRect(
|
||||
color = tint,
|
||||
topLeft = Offset(inset, inset),
|
||||
size = Size(size.width - inset * 2f, size.height - inset * 2f),
|
||||
cornerRadius = CornerRadius(corner, corner),
|
||||
style = Stroke(width = stroke),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Restored-from-maximize: overlapping rounded tiles. */
|
||||
@Composable
|
||||
private fun TitleBarRestoreIcon(tint: Color) {
|
||||
Canvas(Modifier.size(16.dp)) {
|
||||
val stroke = size.height * 0.06f
|
||||
val corner = size.width * 0.1f
|
||||
val backSize = size.width * 0.46f
|
||||
val frontSize = size.width * 0.52f
|
||||
drawRoundRect(
|
||||
color = tint,
|
||||
topLeft = Offset(size.width * 0.28f, size.height * 0.16f),
|
||||
size = Size(backSize, backSize),
|
||||
cornerRadius = CornerRadius(corner, corner),
|
||||
style = Stroke(width = stroke),
|
||||
)
|
||||
drawRoundRect(
|
||||
color = tint,
|
||||
topLeft = Offset(size.width * 0.2f, size.height * 0.28f),
|
||||
size = Size(frontSize, frontSize),
|
||||
cornerRadius = CornerRadius(corner, corner),
|
||||
style = Stroke(width = stroke),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import com.sun.jna.Library
|
||||
import com.sun.jna.Memory
|
||||
import com.sun.jna.Native
|
||||
import com.sun.jna.platform.win32.WinDef.HWND
|
||||
import java.awt.Window
|
||||
|
||||
private const val DWMWA_TRANSITIONS_FORCEDISABLED = 3
|
||||
private const val DWMWA_WINDOW_CORNER_PREFERENCE = 33
|
||||
private const val DWMWCP_ROUND = 2
|
||||
|
||||
private interface DwmapiLib : Library {
|
||||
fun DwmSetWindowAttribute(
|
||||
hwnd: HWND,
|
||||
dwAttribute: Int,
|
||||
pvAttribute: Memory,
|
||||
cbAttribute: Int,
|
||||
): Int
|
||||
|
||||
companion object {
|
||||
val INSTANCE: DwmapiLib = Native.load("dwmapi", DwmapiLib::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opt in to Windows 11 rounded window corners for undecorated custom chrome. */
|
||||
internal fun applyWindowsRoundedCorners(window: Window) {
|
||||
if (!isWindowsOs()) return
|
||||
val hwnd = window.windowsHwnd()
|
||||
if (hwnd.pointer == null) return
|
||||
setDwmInt(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND)
|
||||
setDwmInt(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED, 0)
|
||||
}
|
||||
|
||||
internal fun setDwmTransitionsForcedDisabled(hwnd: HWND, disabled: Boolean) {
|
||||
setDwmInt(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED, if (disabled) 1 else 0)
|
||||
}
|
||||
|
||||
private fun setDwmInt(hwnd: HWND, attribute: Int, value: Int) {
|
||||
val memory = Memory(4)
|
||||
memory.setInt(0, value)
|
||||
DwmapiLib.INSTANCE.DwmSetWindowAttribute(hwnd, attribute, memory, 4)
|
||||
}
|
||||
|
||||
internal fun applyWindowsRoundedCorners(composeWindow: androidx.compose.ui.awt.ComposeWindow) {
|
||||
applyWindowsRoundedCorners(composeWindow as Window)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import com.sun.jna.platform.win32.User32
|
||||
import com.sun.jna.platform.win32.WinDef.LPARAM
|
||||
import com.sun.jna.platform.win32.WinDef.WPARAM
|
||||
import com.sun.jna.platform.win32.WinUser
|
||||
import java.awt.Frame
|
||||
import java.awt.Window
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import java.awt.event.WindowStateListener
|
||||
|
||||
/** Tracks whether the AWT frame is the active (foreground) window. */
|
||||
@Composable
|
||||
internal fun rememberWindowsFrameActive(window: Window): Boolean {
|
||||
var active by remember(window) {
|
||||
mutableStateOf(window.isActive)
|
||||
}
|
||||
DisposableEffect(window) {
|
||||
val listener = object : WindowAdapter() {
|
||||
override fun windowActivated(event: WindowEvent?) {
|
||||
active = true
|
||||
}
|
||||
|
||||
override fun windowDeactivated(event: WindowEvent?) {
|
||||
active = false
|
||||
}
|
||||
}
|
||||
window.addWindowListener(listener)
|
||||
active = window.isActive
|
||||
onDispose { window.removeWindowListener(listener) }
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
internal fun Window.windowsMinimize() {
|
||||
User32.INSTANCE.SendMessage(
|
||||
windowsHwnd(),
|
||||
WinUser.WM_SYSCOMMAND,
|
||||
WPARAM(SC_MINIMIZE),
|
||||
LPARAM(0),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun Window.windowsToggleMaximize(windowState: WindowState) {
|
||||
val hwnd = windowsHwnd()
|
||||
if (hwnd.isNativeZoomed() || windowState.placement == WindowPlacement.Maximized) {
|
||||
User32.INSTANCE.SendMessage(hwnd, WinUser.WM_SYSCOMMAND, WPARAM(SC_RESTORE), LPARAM(0))
|
||||
windowState.placement = WindowPlacement.Floating
|
||||
} else {
|
||||
User32.INSTANCE.SendMessage(hwnd, WinUser.WM_SYSCOMMAND, WPARAM(SC_MAXIMIZE), LPARAM(0))
|
||||
windowState.placement = WindowPlacement.Maximized
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun Window.syncWindowsPlacementFromNative(windowState: WindowState) {
|
||||
DisposableEffect(this, windowState) {
|
||||
val frame = this@syncWindowsPlacementFromNative as? Frame
|
||||
if (frame == null) {
|
||||
onDispose {}
|
||||
} else {
|
||||
val listener = WindowStateListener { event ->
|
||||
windowState.placement =
|
||||
if (event.newState and Frame.MAXIMIZED_BOTH != 0) {
|
||||
WindowPlacement.Maximized
|
||||
} else {
|
||||
WindowPlacement.Floating
|
||||
}
|
||||
}
|
||||
frame.addWindowStateListener(listener)
|
||||
onDispose { frame.removeWindowStateListener(listener) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val SC_MINIMIZE = 0xF020L
|
||||
private const val SC_MAXIMIZE = 0xF030L
|
||||
private const val SC_RESTORE = 0xF120L
|
||||
@@ -0,0 +1,251 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import androidx.compose.ui.awt.ComposeWindow
|
||||
import com.sun.jna.Native
|
||||
import com.sun.jna.NativeLibrary
|
||||
import com.sun.jna.Pointer
|
||||
import com.sun.jna.platform.win32.BaseTSD.LONG_PTR
|
||||
import com.sun.jna.platform.win32.User32
|
||||
import com.sun.jna.platform.win32.WinDef.HWND
|
||||
import com.sun.jna.platform.win32.WinDef.LPARAM
|
||||
import com.sun.jna.platform.win32.WinDef.LRESULT
|
||||
import com.sun.jna.platform.win32.WinDef.RECT
|
||||
import com.sun.jna.platform.win32.WinDef.WPARAM
|
||||
import com.sun.jna.platform.win32.WinUser
|
||||
import com.sun.jna.platform.win32.WinUser.WindowProc
|
||||
import com.sun.jna.win32.W32APIOptions
|
||||
import java.awt.Color
|
||||
import java.awt.Window
|
||||
import javax.swing.JFrame
|
||||
|
||||
private const val ChromeKey = "fromchat.windowsNativeChrome"
|
||||
private const val ChromeBackgroundKey = "fromchat.windowsNativeChromeBackground"
|
||||
private const val WM_NCCALCSIZE = 0x0083
|
||||
private const val WM_ERASEBKGND = 0x0014
|
||||
private const val GWLP_WNDPROC = -4
|
||||
|
||||
/**
|
||||
* Makes the undecorated Compose window look borderless while remaining an overlapped
|
||||
* Win32 window, so maximize/minimize use DWM animations and maximize to the work area.
|
||||
*
|
||||
* [undecorated][androidx.compose.ui.window.Window] creates a `WS_POPUP` window. Popup
|
||||
* maximize is fullscreen (no animation). Restoring `WS_CAPTION` and handling
|
||||
* `WM_NCCALCSIZE` is the same approach as FlatLaf native decorations and
|
||||
* rossy/borderless-window.
|
||||
*/
|
||||
internal fun installWindowsNativeCaptionChrome(window: Window) {
|
||||
if (!isWindowsOs()) return
|
||||
val frame = window as? JFrame ?: return
|
||||
if (frame.rootPane.getClientProperty(ChromeKey) != null) return
|
||||
val hwnd = window.windowsHwnd()
|
||||
if (hwnd.pointer == null) return
|
||||
val background = frame.background ?: Color(0x1C, 0x1B, 0x1F)
|
||||
frame.rootPane.putClientProperty(ChromeBackgroundKey, background)
|
||||
frame.rootPane.putClientProperty(ChromeKey, WindowsCaptionWndProc(hwnd, background))
|
||||
}
|
||||
|
||||
internal fun updateWindowsNativeCaptionBackground(window: Window, background: Color) {
|
||||
if (!isWindowsOs()) return
|
||||
val frame = window as? JFrame ?: return
|
||||
frame.rootPane.putClientProperty(ChromeBackgroundKey, background)
|
||||
(frame.rootPane.getClientProperty(ChromeKey) as? WindowsCaptionWndProc)?.background = background
|
||||
}
|
||||
|
||||
private const val GwlExStyle = -20
|
||||
private const val WsExToolWindow = 0x00000080
|
||||
private const val WsExAppWindow = 0x00040000
|
||||
|
||||
/** Keeps transient popups (tray menu, etc.) out of the Windows taskbar and Alt+Tab. */
|
||||
internal fun excludeFromWindowsTaskbar(window: Window) {
|
||||
if (!isWindowsOs()) return
|
||||
val hwnd = window.windowsHwnd()
|
||||
if (hwnd.pointer == null) return
|
||||
val exStyle = User32.INSTANCE.GetWindowLong(hwnd, GwlExStyle)
|
||||
User32.INSTANCE.SetWindowLong(
|
||||
hwnd,
|
||||
GwlExStyle,
|
||||
(exStyle or WsExToolWindow) and WsExAppWindow.inv(),
|
||||
)
|
||||
User32.INSTANCE.SetWindowPos(
|
||||
hwnd,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
WinUser.SWP_NOMOVE or
|
||||
WinUser.SWP_NOSIZE or
|
||||
WinUser.SWP_NOZORDER or
|
||||
WinUser.SWP_NOACTIVATE or
|
||||
WinUser.SWP_FRAMECHANGED,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun Window.windowsHwnd(): HWND {
|
||||
val handle = (this as? ComposeWindow)?.windowHandle ?: 0L
|
||||
return if (handle != 0L) {
|
||||
HWND(Pointer(handle))
|
||||
} else {
|
||||
HWND(Native.getComponentPointer(this))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun HWND.isNativeZoomed(): Boolean =
|
||||
User32.INSTANCE.GetWindowLong(this, WinUser.GWL_STYLE) and WS_MAXIMIZE != 0
|
||||
|
||||
internal fun Window.isNativeZoomed(): Boolean = windowsHwnd().isNativeZoomed()
|
||||
|
||||
private const val WS_MAXIMIZE = 0x01000000
|
||||
|
||||
@Suppress("FunctionName")
|
||||
private interface User32Ex : User32 {
|
||||
fun SetWindowLong(hWnd: HWND, nIndex: Int, wndProc: WindowProc): LONG_PTR
|
||||
fun SetWindowLongPtr(hWnd: HWND, nIndex: Int, wndProc: WindowProc): LONG_PTR
|
||||
fun CallWindowProc(proc: LONG_PTR, hWnd: HWND, uMsg: Int, wParam: WPARAM, lParam: LPARAM): LRESULT
|
||||
}
|
||||
|
||||
private class WindowsCaptionWndProc(
|
||||
private val hwnd: HWND,
|
||||
@Volatile var background: Color,
|
||||
) : WindowProc {
|
||||
private val user32 = runCatching {
|
||||
Native.load("user32", User32Ex::class.java, W32APIOptions.DEFAULT_OPTIONS)
|
||||
}.getOrNull()
|
||||
|
||||
private val defaultWndProc = when {
|
||||
user32 == null -> LONG_PTR(-1)
|
||||
is64Bit() -> user32.SetWindowLongPtr(hwnd, GWLP_WNDPROC, this)
|
||||
else -> user32.SetWindowLong(hwnd, GWLP_WNDPROC, this)
|
||||
}
|
||||
|
||||
init {
|
||||
val style = User32.INSTANCE.GetWindowLong(hwnd, WinUser.GWL_STYLE)
|
||||
User32.INSTANCE.SetWindowLong(
|
||||
hwnd,
|
||||
WinUser.GWL_STYLE,
|
||||
style and WinUser.WS_POPUP.inv() or
|
||||
WinUser.WS_CAPTION or
|
||||
WinUser.WS_THICKFRAME or
|
||||
WinUser.WS_SYSMENU or
|
||||
WinUser.WS_MINIMIZEBOX or
|
||||
WinUser.WS_MAXIMIZEBOX,
|
||||
)
|
||||
extendFrameIntoClientArea(hwnd)
|
||||
setDwmTransitionsForcedDisabled(hwnd, disabled = false)
|
||||
User32.INSTANCE.SetWindowPos(
|
||||
hwnd,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
WinUser.SWP_NOMOVE or
|
||||
WinUser.SWP_NOSIZE or
|
||||
WinUser.SWP_NOZORDER or
|
||||
WinUser.SWP_NOACTIVATE or
|
||||
WinUser.SWP_FRAMECHANGED,
|
||||
)
|
||||
}
|
||||
|
||||
override fun callback(hWnd: HWND, uMsg: Int, wParam: WPARAM, lParam: LPARAM): LRESULT =
|
||||
when (uMsg) {
|
||||
WM_ERASEBKGND -> {
|
||||
eraseClientBackground(wParam)
|
||||
LRESULT(1)
|
||||
}
|
||||
WM_NCCALCSIZE -> handleNcCalcSize(hWnd, wParam, lParam)
|
||||
else -> callDefault(hWnd, uMsg, wParam, lParam)
|
||||
}
|
||||
|
||||
private fun eraseClientBackground(wParam: WPARAM) {
|
||||
runCatching {
|
||||
val gdi = NativeLibrary.getInstance("gdi32")
|
||||
val hdc = Pointer(wParam.toLong())
|
||||
val rect = RECT()
|
||||
if (!User32.INSTANCE.GetClientRect(hwnd, rect)) return
|
||||
val brush = gdi.getFunction("CreateSolidBrush")
|
||||
.invoke(arrayOf(background.toColorRef())) as? Pointer ?: return
|
||||
gdi.getFunction("FillRect").invoke(arrayOf(hdc, rect, brush))
|
||||
gdi.getFunction("DeleteObject").invoke(arrayOf(brush))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNcCalcSize(hWnd: HWND, wParam: WPARAM, lParam: LPARAM): LRESULT {
|
||||
if (wParam.toLong() == 0L) {
|
||||
return callDefault(hWnd, WM_NCCALCSIZE, wParam, lParam)
|
||||
}
|
||||
val pointer = Pointer(lParam.toLong())
|
||||
val before = RECT().apply {
|
||||
left = pointer.getInt(0)
|
||||
top = pointer.getInt(4)
|
||||
right = pointer.getInt(8)
|
||||
bottom = pointer.getInt(12)
|
||||
}
|
||||
callDefault(hWnd, WM_NCCALCSIZE, wParam, lParam)
|
||||
if (hWnd.isNativeZoomed()) {
|
||||
applyMaximizedWorkArea(lParam)
|
||||
} else {
|
||||
pointer.setInt(0, before.left)
|
||||
pointer.setInt(4, before.top)
|
||||
pointer.setInt(8, before.right)
|
||||
pointer.setInt(12, before.bottom)
|
||||
}
|
||||
return LRESULT(0)
|
||||
}
|
||||
|
||||
private fun callDefault(hWnd: HWND, uMsg: Int, wParam: WPARAM, lParam: LPARAM): LRESULT {
|
||||
val lib = user32 ?: return LRESULT(0)
|
||||
return if (defaultWndProc.toLong() == -1L) {
|
||||
User32.INSTANCE.DefWindowProc(hWnd, uMsg, wParam, lParam)
|
||||
} else {
|
||||
lib.CallWindowProc(defaultWndProc, hWnd, uMsg, wParam, lParam)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyMaximizedWorkArea(lParam: LPARAM) {
|
||||
val pointer = Pointer(lParam.toLong())
|
||||
val proposed = RECT().apply {
|
||||
left = pointer.getInt(0)
|
||||
top = pointer.getInt(4)
|
||||
right = pointer.getInt(8)
|
||||
bottom = pointer.getInt(12)
|
||||
}
|
||||
val monitor = User32.INSTANCE.MonitorFromRect(proposed, WinUser.MONITOR_DEFAULTTONEAREST)
|
||||
?: return
|
||||
val info = WinUser.MONITORINFO()
|
||||
User32.INSTANCE.GetMonitorInfo(monitor, info)
|
||||
val work = info.rcWork
|
||||
if (work.right <= work.left || work.bottom <= work.top) return
|
||||
pointer.setInt(0, work.left)
|
||||
pointer.setInt(4, work.top)
|
||||
pointer.setInt(8, work.right)
|
||||
pointer.setInt(12, work.bottom)
|
||||
}
|
||||
|
||||
private fun extendFrameIntoClientArea(hwnd: HWND) {
|
||||
runCatching { NativeLibrary.getInstance("dwmapi") }
|
||||
.getOrNull()
|
||||
?.runCatching { getFunction("DwmExtendFrameIntoClientArea") }
|
||||
?.getOrNull()
|
||||
?.invoke(arrayOf(hwnd, DwmMargins()))
|
||||
}
|
||||
|
||||
private fun is64Bit(): Boolean =
|
||||
System.getProperty("sun.arch.data.model") == "64"
|
||||
|
||||
@com.sun.jna.Structure.FieldOrder(
|
||||
"cxLeftWidth",
|
||||
"cxRightWidth",
|
||||
"cyTopHeight",
|
||||
"cyBottomHeight",
|
||||
)
|
||||
internal class DwmMargins(
|
||||
@JvmField var cxLeftWidth: Int = 0,
|
||||
@JvmField var cxRightWidth: Int = 0,
|
||||
@JvmField var cyTopHeight: Int = 0,
|
||||
@JvmField var cyBottomHeight: Int = 0,
|
||||
) : com.sun.jna.Structure(), com.sun.jna.Structure.ByReference
|
||||
|
||||
private fun Color.toColorRef(): Int =
|
||||
(blue and 0xFF shl 16) or (green and 0xFF shl 8) or (red and 0xFF)
|
||||
@@ -0,0 +1,155 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import com.sun.jna.Native
|
||||
import com.sun.jna.Structure
|
||||
import com.sun.jna.platform.win32.WinBase
|
||||
import ru.fromchat.Logger
|
||||
import com.sun.jna.platform.win32.WinDef.DWORD
|
||||
import com.sun.jna.platform.win32.WinNT
|
||||
import com.sun.jna.ptr.IntByReference
|
||||
import com.sun.jna.win32.StdCallLibrary
|
||||
import com.sun.jna.win32.W32APIOptions
|
||||
import java.io.File
|
||||
import java.lang.ProcessHandle
|
||||
|
||||
internal object WindowsRestartManager {
|
||||
private const val ERROR_MORE_DATA = 234
|
||||
private const val CCH_RM_MAX_APP_NAME = 255
|
||||
private const val CCH_RM_MAX_SVC_NAME = 63
|
||||
|
||||
fun findLockingProcesses(files: List<File>): List<LockingProcessInfo> {
|
||||
if (!isWindowsOs()) return emptyList()
|
||||
val paths = files
|
||||
.map { file -> runCatching { file.canonicalPath }.getOrElse { file.absolutePath } }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
if (paths.isEmpty()) return emptyList()
|
||||
|
||||
val session = IntByReference()
|
||||
val sessionKey = CharArray(33)
|
||||
val start = RestartManager.INSTANCE.RmStartSession(session, 0, sessionKey)
|
||||
if (start != WinNT.ERROR_SUCCESS) {
|
||||
Logger.w("WindowsRestartManager", "RmStartSession failed: $start")
|
||||
return emptyList()
|
||||
}
|
||||
val sessionHandle = session.value
|
||||
|
||||
try {
|
||||
val pathArray = paths.toTypedArray()
|
||||
val register = RestartManager.INSTANCE.RmRegisterResources(
|
||||
sessionHandle,
|
||||
pathArray.size,
|
||||
pathArray,
|
||||
0,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
)
|
||||
if (register != WinNT.ERROR_SUCCESS) {
|
||||
Logger.w("WindowsRestartManager", "RmRegisterResources failed: $register paths=$paths")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val needed = IntByReference()
|
||||
val count = IntByReference()
|
||||
var result = RestartManager.INSTANCE.RmGetList(
|
||||
sessionHandle,
|
||||
needed,
|
||||
count,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
if (result != ERROR_MORE_DATA && result != WinNT.ERROR_SUCCESS) {
|
||||
Logger.w("WindowsRestartManager", "RmGetList probe failed: $result")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val processCount = needed.value.coerceAtLeast(count.value)
|
||||
if (processCount <= 0) return emptyList()
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val processes = RmProcessInfo().toArray(processCount) as Array<RmProcessInfo>
|
||||
count.value = processCount
|
||||
result = RestartManager.INSTANCE.RmGetList(
|
||||
sessionHandle,
|
||||
needed,
|
||||
count,
|
||||
processes,
|
||||
null,
|
||||
)
|
||||
if (result != WinNT.ERROR_SUCCESS) {
|
||||
Logger.w("WindowsRestartManager", "RmGetList failed: $result")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val currentPid = ProcessHandle.current().pid()
|
||||
return processes
|
||||
.take(count.value.coerceAtMost(processes.size))
|
||||
.onEach { it.read() }
|
||||
.mapNotNull { info ->
|
||||
val pid = info.dwProcessId.toLong()
|
||||
if (pid <= 0L || pid == currentPid) return@mapNotNull null
|
||||
val appName = info.appName().trim()
|
||||
val executable = ProcessExecutableResolver.executablePathForPid(pid)
|
||||
LockingProcessInfo(
|
||||
pid = pid,
|
||||
name = appName.ifBlank { executable?.let { File(it).name } ?: "PID $pid" },
|
||||
description = executable ?: ProcessExecutableResolver.commandLineForPid(pid),
|
||||
executablePath = executable,
|
||||
icon = ProcessExecutableResolver.iconForExecutable(executable),
|
||||
)
|
||||
}
|
||||
.distinctBy { it.pid }
|
||||
} finally {
|
||||
RestartManager.INSTANCE.RmEndSession(sessionHandle)
|
||||
}
|
||||
}
|
||||
|
||||
@Structure.FieldOrder(
|
||||
"dwProcessId",
|
||||
"processStartTime",
|
||||
"strAppName",
|
||||
"strServiceShortName",
|
||||
"applicationType",
|
||||
"appStatus",
|
||||
"tsSessionId",
|
||||
"restartable",
|
||||
)
|
||||
private class RmProcessInfo : Structure() {
|
||||
@JvmField var dwProcessId = 0
|
||||
@JvmField var processStartTime = WinBase.FILETIME()
|
||||
@JvmField var strAppName = CharArray(CCH_RM_MAX_APP_NAME + 1)
|
||||
@JvmField var strServiceShortName = CharArray(CCH_RM_MAX_SVC_NAME + 1)
|
||||
@JvmField var applicationType = DWORD(0)
|
||||
@JvmField var appStatus = DWORD(0)
|
||||
@JvmField var tsSessionId = DWORD(0)
|
||||
@JvmField var restartable = 0
|
||||
|
||||
fun appName(): String = Native.toString(strAppName)
|
||||
}
|
||||
|
||||
private interface RestartManager : StdCallLibrary {
|
||||
fun RmStartSession(sessionHandle: IntByReference, sessionFlags: Int, sessionKey: CharArray): Int
|
||||
fun RmEndSession(sessionHandle: Int): Int
|
||||
fun RmRegisterResources(
|
||||
sessionHandle: Int,
|
||||
fileCount: Int,
|
||||
fileNames: Array<String>?,
|
||||
serviceCount: Int,
|
||||
serviceNames: Array<String>?,
|
||||
processCount: Int,
|
||||
processIds: IntArray?,
|
||||
): Int
|
||||
fun RmGetList(
|
||||
sessionHandle: Int,
|
||||
procInfoNeeded: IntByReference,
|
||||
procInfoCount: IntByReference,
|
||||
affectedApps: Array<RmProcessInfo>?,
|
||||
rebootReasons: IntByReference?,
|
||||
): Int
|
||||
|
||||
companion object {
|
||||
val INSTANCE: RestartManager = Native.load("Rstrtmgr", RestartManager::class.java, W32APIOptions.DEFAULT_OPTIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package ru.fromchat.desktop
|
||||
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import com.sun.jna.platform.win32.User32
|
||||
import com.sun.jna.platform.win32.WinUser
|
||||
import java.awt.Rectangle
|
||||
import java.awt.Window
|
||||
import kotlin.math.abs
|
||||
|
||||
internal fun windowsWorkAreaBounds(window: Window): Rectangle? {
|
||||
if (!isWindowsOs()) return null
|
||||
val hwnd = window.windowsHwnd()
|
||||
if (hwnd.pointer == null) return null
|
||||
val monitor = User32.INSTANCE.MonitorFromWindow(hwnd, WinUser.MONITOR_DEFAULTTONEAREST)
|
||||
?: return null
|
||||
val info = WinUser.MONITORINFO()
|
||||
if (!User32.INSTANCE.GetMonitorInfo(monitor, info).booleanValue()) return null
|
||||
val work = info.rcWork
|
||||
if (work.right <= work.left || work.bottom <= work.top) return null
|
||||
return Rectangle(
|
||||
work.left,
|
||||
work.top,
|
||||
work.right - work.left,
|
||||
work.bottom - work.top,
|
||||
)
|
||||
}
|
||||
|
||||
/** Keeps a floating window inside the monitor work area (excludes taskbar). */
|
||||
internal fun clampFloatingAwtWindowToWorkArea(window: Window): Boolean {
|
||||
if (!isWindowsOs()) return false
|
||||
if (window.isNativeZoomed()) return false
|
||||
val work = windowsWorkAreaBounds(window) ?: return false
|
||||
val current = window.bounds
|
||||
val clamped = clampRectangleToWorkArea(current, work)
|
||||
if (clamped == current) return false
|
||||
window.setBounds(clamped)
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun applyFloatingGeometry(
|
||||
window: Window,
|
||||
windowState: WindowState,
|
||||
size: DpSize,
|
||||
position: WindowPosition,
|
||||
density: Density,
|
||||
) {
|
||||
with(density) {
|
||||
val widthPx = size.width.roundToPx().coerceAtLeast(1)
|
||||
val heightPx = size.height.roundToPx().coerceAtLeast(1)
|
||||
val (x, y) = when (position) {
|
||||
is WindowPosition.Absolute -> {
|
||||
position.x.roundToPx() to position.y.roundToPx()
|
||||
}
|
||||
else -> window.location.x to window.location.y
|
||||
}
|
||||
window.setBounds(x, y, widthPx, heightPx)
|
||||
windowState.size = size
|
||||
if (position is WindowPosition.Absolute) {
|
||||
windowState.position = position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun applySavedFloatingGeometry(
|
||||
window: Window,
|
||||
windowState: WindowState,
|
||||
density: Density,
|
||||
) {
|
||||
applyFloatingGeometry(
|
||||
window,
|
||||
windowState,
|
||||
DesktopWindowPrefs.loadSize(),
|
||||
DesktopWindowPrefs.loadPosition(),
|
||||
density,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun captureFloatingGeometryToPrefs(
|
||||
window: Window,
|
||||
windowState: WindowState,
|
||||
density: Density,
|
||||
) {
|
||||
if (windowState.placement != WindowPlacement.Floating || window.isNativeZoomed()) return
|
||||
val bounds = window.bounds
|
||||
with(density) {
|
||||
DesktopWindowPrefs.save(
|
||||
size = DpSize(bounds.width.toDp(), bounds.height.toDp()),
|
||||
position = WindowPosition(bounds.x.toDp(), bounds.y.toDp()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun syncComposeWindowStateFromAwt(
|
||||
window: Window,
|
||||
windowState: WindowState,
|
||||
density: Density,
|
||||
) {
|
||||
if (windowState.placement != WindowPlacement.Floating) return
|
||||
val bounds = window.bounds
|
||||
with(density) {
|
||||
windowState.size = DpSize(bounds.width.toDp(), bounds.height.toDp())
|
||||
windowState.position = WindowPosition(bounds.x.toDp(), bounds.y.toDp())
|
||||
}
|
||||
}
|
||||
|
||||
internal fun floatingWindowCoversWorkArea(window: Window, tolerancePx: Int = 8): Boolean {
|
||||
val work = windowsWorkAreaBounds(window) ?: return false
|
||||
val bounds = window.bounds
|
||||
return abs(bounds.x - work.x) <= tolerancePx &&
|
||||
abs(bounds.y - work.y) <= tolerancePx &&
|
||||
abs(bounds.width - work.width) <= tolerancePx &&
|
||||
abs(bounds.height - work.height) <= tolerancePx
|
||||
}
|
||||
|
||||
private fun clampRectangleToWorkArea(bounds: Rectangle, work: Rectangle): Rectangle {
|
||||
val width = bounds.width.coerceAtMost(work.width).coerceAtLeast(320)
|
||||
val height = bounds.height.coerceAtMost(work.height).coerceAtLeast(240)
|
||||
val maxX = (work.x + work.width - width).coerceAtLeast(work.x)
|
||||
val maxY = (work.y + work.height - height).coerceAtLeast(work.y)
|
||||
return Rectangle(
|
||||
bounds.x.coerceIn(work.x, maxX),
|
||||
bounds.y.coerceIn(work.y, maxY),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,468 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <CoreGraphics/CoreGraphics.h>
|
||||
#import <CoreServices/CoreServices.h>
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UserNotifications/UserNotifications.h>
|
||||
#import <jni.h>
|
||||
|
||||
@class FromChatNotificationDelegate;
|
||||
|
||||
static JavaVM *fromchatJvm = NULL;
|
||||
static FromChatNotificationDelegate *fromchatDelegate = nil;
|
||||
static jclass fromchatMacNotificationCenterClass = NULL;
|
||||
|
||||
@interface FromChatNotificationDelegate : NSObject <UNUserNotificationCenterDelegate>
|
||||
@end
|
||||
|
||||
static void fromchatCallJavaStaticVoid(const char *methodName, const char *utfArg);
|
||||
|
||||
@implementation FromChatNotificationDelegate
|
||||
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
|
||||
willPresentNotification:(UNNotification *)notification
|
||||
withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler {
|
||||
UNNotificationPresentationOptions options =
|
||||
UNNotificationPresentationOptionBanner |
|
||||
UNNotificationPresentationOptionList |
|
||||
UNNotificationPresentationOptionSound |
|
||||
UNNotificationPresentationOptionBadge;
|
||||
NSLog(
|
||||
@"FromChat UN willPresent id=%@ nsAppActive=%d options=%lu",
|
||||
notification.request.identifier,
|
||||
[NSApp isActive],
|
||||
(unsigned long)options
|
||||
);
|
||||
completionHandler(options);
|
||||
fromchatCallJavaStaticVoid("onNativeWillPresent", notification.request.identifier.UTF8String);
|
||||
}
|
||||
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
|
||||
didReceiveNotificationResponse:(UNNotificationResponse *)response
|
||||
withCompletionHandler:(void (^)(void))completionHandler {
|
||||
NSString *action = response.actionIdentifier;
|
||||
NSString *identifier = response.notification.request.identifier;
|
||||
NSLog(@"FromChat UN response action=%@ id=%@", action, identifier);
|
||||
if ([action isEqualToString:UNNotificationDismissActionIdentifier]) {
|
||||
completionHandler();
|
||||
return;
|
||||
}
|
||||
fromchatCallJavaStaticVoid("onNativeActivated", identifier.UTF8String);
|
||||
completionHandler();
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
static void fromchatCallJavaStaticVoid(const char *methodName, const char *utfArg) {
|
||||
if (fromchatJvm == NULL || fromchatMacNotificationCenterClass == NULL || methodName == NULL) {
|
||||
return;
|
||||
}
|
||||
JNIEnv *env = NULL;
|
||||
jint getEnv = (*fromchatJvm)->GetEnv(fromchatJvm, (void **)&env, JNI_VERSION_1_8);
|
||||
jboolean attachedHere = JNI_FALSE;
|
||||
if (getEnv == JNI_EDETACHED) {
|
||||
if ((*fromchatJvm)->AttachCurrentThread(fromchatJvm, (void **)&env, NULL) != JNI_OK) {
|
||||
return;
|
||||
}
|
||||
attachedHere = JNI_TRUE;
|
||||
}
|
||||
if (env == NULL) return;
|
||||
jmethodID mid = (*env)->GetStaticMethodID(
|
||||
env,
|
||||
fromchatMacNotificationCenterClass,
|
||||
methodName,
|
||||
"(Ljava/lang/String;)V"
|
||||
);
|
||||
if (mid != NULL) {
|
||||
jstring jid = utfArg != NULL ? (*env)->NewStringUTF(env, utfArg) : NULL;
|
||||
(*env)->CallStaticVoidMethod(env, fromchatMacNotificationCenterClass, mid, jid);
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
(*env)->ExceptionDescribe(env);
|
||||
(*env)->ExceptionClear(env);
|
||||
}
|
||||
if (jid != NULL) (*env)->DeleteLocalRef(env, jid);
|
||||
}
|
||||
if (attachedHere) {
|
||||
(*fromchatJvm)->DetachCurrentThread(fromchatJvm);
|
||||
}
|
||||
}
|
||||
|
||||
static BOOL fromchatIsBundledApp(void) {
|
||||
NSString *path = [NSBundle mainBundle].bundlePath;
|
||||
return [path.pathExtension isEqualToString:@"app"];
|
||||
}
|
||||
|
||||
static UNUserNotificationCenter *fromchatNotificationCenter(void) {
|
||||
NSBundle *bundle = [NSBundle mainBundle];
|
||||
if (!fromchatIsBundledApp()) {
|
||||
NSLog(@"FromChat UN skip: not an .app (path=%@ id=%@)", bundle.bundlePath, bundle.bundleIdentifier);
|
||||
return nil;
|
||||
}
|
||||
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
|
||||
NSLog(@"FromChat UN center=%p path=%@ id=%@", center, bundle.bundlePath, bundle.bundleIdentifier);
|
||||
return center;
|
||||
}
|
||||
|
||||
static void fromchatRunOnMain(void (^block)(void)) {
|
||||
if ([NSThread isMainThread]) {
|
||||
block();
|
||||
} else {
|
||||
dispatch_sync(dispatch_get_main_queue(), block);
|
||||
}
|
||||
}
|
||||
|
||||
static void fromchatEnsureDelegate(UNUserNotificationCenter *center) {
|
||||
if (center == nil) return;
|
||||
fromchatRunOnMain(^{
|
||||
if (fromchatDelegate == nil) {
|
||||
fromchatDelegate = [FromChatNotificationDelegate new];
|
||||
}
|
||||
center.delegate = fromchatDelegate;
|
||||
NSLog(@"FromChat UN delegate set on %@", center);
|
||||
});
|
||||
}
|
||||
|
||||
static NSRunningApplication *fromchatOtherFrontApp(void) {
|
||||
NSString *ours = [NSBundle mainBundle].bundleIdentifier;
|
||||
pid_t ourPid = [NSRunningApplication currentApplication].processIdentifier;
|
||||
NSRunningApplication *wsFront = [[NSWorkspace sharedWorkspace] frontmostApplication];
|
||||
if (wsFront != nil && wsFront.processIdentifier != ourPid &&
|
||||
(ours == nil || ![wsFront.bundleIdentifier isEqualToString:ours])) {
|
||||
return wsFront;
|
||||
}
|
||||
CFArrayRef info = CGWindowListCopyWindowInfo(
|
||||
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
|
||||
kCGNullWindowID
|
||||
);
|
||||
if (info == NULL) return nil;
|
||||
NSArray *windows = CFBridgingRelease(info);
|
||||
for (NSDictionary *win in windows) {
|
||||
NSNumber *layer = win[(id)kCGWindowLayer];
|
||||
if (layer == nil || layer.intValue != 0) continue;
|
||||
NSNumber *pidNum = win[(id)kCGWindowOwnerPID];
|
||||
if (pidNum == nil) continue;
|
||||
pid_t pid = pidNum.intValue;
|
||||
if (pid == ourPid) continue;
|
||||
NSRunningApplication *app = [NSRunningApplication runningApplicationWithProcessIdentifier:pid];
|
||||
if (app == nil) continue;
|
||||
if (app.activationPolicy != NSApplicationActivationPolicyRegular) continue;
|
||||
return app;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static BOOL fromchatIsAppFrontmost(void) {
|
||||
__block BOOL frontmost = NO;
|
||||
fromchatRunOnMain(^{
|
||||
NSString *frontId = [[NSWorkspace sharedWorkspace] frontmostApplication].bundleIdentifier;
|
||||
NSString *ours = [NSBundle mainBundle].bundleIdentifier;
|
||||
frontmost = frontId != nil && ours != nil && [frontId isEqualToString:ours];
|
||||
NSLog(
|
||||
@"FromChat frontmost=%d front=%@ ours=%@ nsAppActive=%d",
|
||||
frontmost,
|
||||
frontId,
|
||||
ours,
|
||||
[NSApp isActive]
|
||||
);
|
||||
});
|
||||
return frontmost;
|
||||
}
|
||||
|
||||
static void fromchatResignActive(void) {
|
||||
fromchatRunOnMain(^{
|
||||
[NSApp deactivate];
|
||||
NSRunningApplication *other = fromchatOtherFrontApp();
|
||||
if (other != nil) {
|
||||
if (@available(macOS 14.0, *)) {
|
||||
[NSApp yieldActivationToApplication:other];
|
||||
} else {
|
||||
[other activateWithOptions:NSApplicationActivateIgnoringOtherApps];
|
||||
}
|
||||
}
|
||||
NSLog(
|
||||
@"FromChat resignActive nsAppActive=%d front=%@",
|
||||
[NSApp isActive],
|
||||
[[NSWorkspace sharedWorkspace] frontmostApplication].bundleIdentifier
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static void fromchatYieldIfNotFrontmost(void) {
|
||||
fromchatResignActive();
|
||||
}
|
||||
|
||||
static BOOL fromchatDeliverNotification(
|
||||
UNUserNotificationCenter *center,
|
||||
NSString *title,
|
||||
NSString *body,
|
||||
NSString *subtitle,
|
||||
NSString *identifier,
|
||||
BOOL playSound,
|
||||
BOOL windowFocused
|
||||
) {
|
||||
if (center == nil) return NO;
|
||||
fromchatEnsureDelegate(center);
|
||||
if (windowFocused != YES) {
|
||||
fromchatResignActive();
|
||||
}
|
||||
|
||||
UNMutableNotificationContent *content = [UNMutableNotificationContent new];
|
||||
content.title = title;
|
||||
if (subtitle.length > 0) {
|
||||
content.subtitle = subtitle;
|
||||
}
|
||||
content.body = body;
|
||||
content.sound = playSound ? [UNNotificationSound defaultSound] : nil;
|
||||
if (@available(macOS 12.0, *)) {
|
||||
content.interruptionLevel = UNNotificationInterruptionLevelActive;
|
||||
}
|
||||
|
||||
UNNotificationRequest *request =
|
||||
[UNNotificationRequest requestWithIdentifier:identifier content:content trigger:nil];
|
||||
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
|
||||
__block BOOL ok = NO;
|
||||
[center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {
|
||||
ok = error == nil;
|
||||
NSLog(
|
||||
@"FromChat UN add id=%@ ok=%d focused=%d nsAppActive=%d error=%@",
|
||||
identifier,
|
||||
ok,
|
||||
windowFocused == YES,
|
||||
[NSApp isActive],
|
||||
error
|
||||
);
|
||||
dispatch_semaphore_signal(semaphore);
|
||||
}];
|
||||
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC));
|
||||
return ok;
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
|
||||
fromchatJvm = vm;
|
||||
JNIEnv *env = NULL;
|
||||
if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_8) == JNI_OK && env != NULL) {
|
||||
jclass local = (*env)->FindClass(env, "ru/fromchat/desktop/MacNotificationCenter");
|
||||
if (local != NULL) {
|
||||
fromchatMacNotificationCenterClass = (*env)->NewGlobalRef(env, local);
|
||||
(*env)->DeleteLocalRef(env, local);
|
||||
}
|
||||
}
|
||||
return JNI_VERSION_1_8;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeRequestAuthorization(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
UNUserNotificationCenter *center = fromchatNotificationCenter();
|
||||
if (center == nil) return JNI_FALSE;
|
||||
fromchatEnsureDelegate(center);
|
||||
|
||||
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
|
||||
__block BOOL grantedResult = NO;
|
||||
void (^request)(void) = ^{
|
||||
[center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert |
|
||||
UNAuthorizationOptionSound |
|
||||
UNAuthorizationOptionBadge)
|
||||
completionHandler:^(BOOL granted, NSError * _Nullable error) {
|
||||
grantedResult = granted;
|
||||
NSLog(@"FromChat UN auth granted=%d error=%@", granted, error);
|
||||
dispatch_semaphore_signal(semaphore);
|
||||
}];
|
||||
};
|
||||
if ([NSThread isMainThread]) {
|
||||
request();
|
||||
} else {
|
||||
dispatch_async(dispatch_get_main_queue(), request);
|
||||
}
|
||||
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC));
|
||||
return grantedResult ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeAuthorizationStatus(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
UNUserNotificationCenter *center = fromchatNotificationCenter();
|
||||
if (center == nil) return 0;
|
||||
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
|
||||
__block NSInteger status = 0;
|
||||
[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) {
|
||||
status = settings.authorizationStatus;
|
||||
if (@available(macOS 12.0, *)) {
|
||||
NSLog(
|
||||
@"FromChat UN status=%ld alert=%ld sound=%ld badge=%ld preview=%ld",
|
||||
(long)settings.authorizationStatus,
|
||||
(long)settings.alertSetting,
|
||||
(long)settings.soundSetting,
|
||||
(long)settings.badgeSetting,
|
||||
(long)settings.showPreviewsSetting
|
||||
);
|
||||
} else {
|
||||
NSLog(@"FromChat UN status=%ld alert=%ld sound=%ld badge=%ld",
|
||||
(long)settings.authorizationStatus,
|
||||
(long)settings.alertSetting,
|
||||
(long)settings.soundSetting,
|
||||
(long)settings.badgeSetting);
|
||||
}
|
||||
dispatch_semaphore_signal(semaphore);
|
||||
}];
|
||||
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC));
|
||||
return (jint)status;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeDeliver(
|
||||
JNIEnv *env,
|
||||
jclass cls,
|
||||
jstring jTitle,
|
||||
jstring jBody,
|
||||
jstring jSubtitle,
|
||||
jstring jIdentifier,
|
||||
jboolean playSound,
|
||||
jboolean windowFocused
|
||||
) {
|
||||
UNUserNotificationCenter *center = fromchatNotificationCenter();
|
||||
if (center == nil) return JNI_FALSE;
|
||||
|
||||
const char *titleChars = (*env)->GetStringUTFChars(env, jTitle, NULL);
|
||||
const char *bodyChars = (*env)->GetStringUTFChars(env, jBody, NULL);
|
||||
const char *subtitleChars = jSubtitle ? (*env)->GetStringUTFChars(env, jSubtitle, NULL) : NULL;
|
||||
const char *idChars = (*env)->GetStringUTFChars(env, jIdentifier, NULL);
|
||||
NSString *title = titleChars ? [NSString stringWithUTF8String:titleChars] : @"";
|
||||
NSString *body = bodyChars ? [NSString stringWithUTF8String:bodyChars] : @"";
|
||||
NSString *subtitle = subtitleChars ? [NSString stringWithUTF8String:subtitleChars] : @"";
|
||||
NSString *identifier = idChars ? [NSString stringWithUTF8String:idChars] : [[NSUUID UUID] UUIDString];
|
||||
|
||||
__block BOOL ok = NO;
|
||||
void (^deliver)(void) = ^{
|
||||
ok = fromchatDeliverNotification(
|
||||
center,
|
||||
title,
|
||||
body,
|
||||
subtitle,
|
||||
identifier,
|
||||
playSound == JNI_TRUE,
|
||||
windowFocused
|
||||
);
|
||||
};
|
||||
if ([NSThread isMainThread]) {
|
||||
deliver();
|
||||
} else {
|
||||
dispatch_sync(dispatch_get_main_queue(), deliver);
|
||||
}
|
||||
|
||||
if (titleChars) (*env)->ReleaseStringUTFChars(env, jTitle, titleChars);
|
||||
if (bodyChars) (*env)->ReleaseStringUTFChars(env, jBody, bodyChars);
|
||||
if (subtitleChars) (*env)->ReleaseStringUTFChars(env, jSubtitle, subtitleChars);
|
||||
if (idChars) (*env)->ReleaseStringUTFChars(env, jIdentifier, idChars);
|
||||
return ok ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeRemove(
|
||||
JNIEnv *env,
|
||||
jclass cls,
|
||||
jobjectArray jIdentifiers
|
||||
) {
|
||||
UNUserNotificationCenter *center = fromchatNotificationCenter();
|
||||
if (center == nil || jIdentifiers == NULL) return;
|
||||
jsize count = (*env)->GetArrayLength(env, jIdentifiers);
|
||||
NSMutableArray<NSString *> *ids = [NSMutableArray arrayWithCapacity:(NSUInteger)count];
|
||||
for (jsize i = 0; i < count; i++) {
|
||||
jstring jId = (*env)->GetObjectArrayElement(env, jIdentifiers, i);
|
||||
if (jId == NULL) continue;
|
||||
const char *chars = (*env)->GetStringUTFChars(env, jId, NULL);
|
||||
if (chars) {
|
||||
[ids addObject:[NSString stringWithUTF8String:chars]];
|
||||
(*env)->ReleaseStringUTFChars(env, jId, chars);
|
||||
}
|
||||
(*env)->DeleteLocalRef(env, jId);
|
||||
}
|
||||
NSLog(@"FromChat UN remove count=%lu", (unsigned long)ids.count);
|
||||
[center removeDeliveredNotificationsWithIdentifiers:ids];
|
||||
[center removePendingNotificationRequestsWithIdentifiers:ids];
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeRemoveAll(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
UNUserNotificationCenter *center = fromchatNotificationCenter();
|
||||
if (center == nil) return;
|
||||
NSLog(@"FromChat UN removeAll");
|
||||
[center removeAllDeliveredNotifications];
|
||||
[center removeAllPendingNotificationRequests];
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeOpenSettings(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
NSURL *url = [NSURL URLWithString:
|
||||
@"x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=ru.fromchat.desktop"];
|
||||
if (url == nil) return JNI_FALSE;
|
||||
BOOL opened = [[NSWorkspace sharedWorkspace] openURL:url];
|
||||
if (!opened) {
|
||||
url = [NSURL URLWithString:@"x-apple.systempreferences:com.apple.preference.notifications"];
|
||||
opened = url != nil && [[NSWorkspace sharedWorkspace] openURL:url];
|
||||
}
|
||||
return opened ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeDebugInfo(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
__block NSString *info = @"";
|
||||
fromchatRunOnMain(^{
|
||||
NSBundle *bundle = [NSBundle mainBundle];
|
||||
NSString *frontId = [[NSWorkspace sharedWorkspace] frontmostApplication].bundleIdentifier;
|
||||
NSString *ours = bundle.bundleIdentifier;
|
||||
BOOL frontmost = frontId != nil && ours != nil && [frontId isEqualToString:ours];
|
||||
info = [NSString stringWithFormat:
|
||||
@"bundlePath=%@ bundleId=%@ bundled=%d frontmost=%d front=%@ nsAppActive=%d",
|
||||
bundle.bundlePath,
|
||||
ours ?: @"(null)",
|
||||
fromchatIsBundledApp() ? 1 : 0,
|
||||
frontmost ? 1 : 0,
|
||||
frontId ?: @"(null)",
|
||||
[NSApp isActive] ? 1 : 0];
|
||||
});
|
||||
return (*env)->NewStringUTF(env, info.UTF8String);
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeIsAppFrontmost(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
return fromchatIsAppFrontmost() ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeResignActive(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
fromchatResignActive();
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeYieldActivation(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
fromchatYieldIfNotFrontmost();
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeIsBundled(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
return fromchatIsBundledApp() ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeRegisterBundle(
|
||||
JNIEnv *env,
|
||||
jclass cls
|
||||
) {
|
||||
if (!fromchatIsBundledApp()) return;
|
||||
NSURL *url = [NSBundle mainBundle].bundleURL;
|
||||
if (url == nil) return;
|
||||
LSRegisterURL((__bridge CFURLRef)url, true);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+4273
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"common",
|
||||
"setup",
|
||||
"helper",
|
||||
"portable-launcher",
|
||||
"pack",
|
||||
"icon-patch",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
fromchat-installer-common = { path = "common" }
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
zstd = "0.13"
|
||||
tar = "0.4"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
eframe = { version = "0.29", default-features = false, features = ["default_fonts", "glow", "wgpu"] }
|
||||
egui = "0.29"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
@@ -0,0 +1,6 @@
|
||||
# Программа установки FromChat
|
||||
|
||||
*Честно признаюсь, это 100% вайбкод. Rust я не знаю. Просьба не предьявлять претензии по этому поводу.*
|
||||
|
||||
Это программа для установки FromChat на Windows. У нее есть 2 режима: портативная версия (распакует в папку) и полная (с регистрацией, ярлыком на рабочем столе и т.д.).
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>FromChat installer welcome backdrop</title>
|
||||
<style>
|
||||
/* Exact tokens from dmg-background/styles.css */
|
||||
:root {
|
||||
--brand-conic: conic-gradient(
|
||||
from 0deg,
|
||||
rgba(147, 51, 234, 0.5) 0%,
|
||||
rgba(99, 102, 241, 0.6) 12.5%,
|
||||
rgba(59, 130, 246, 0.55) 25%,
|
||||
rgba(168, 85, 247, 0.5) 37.5%,
|
||||
rgba(217, 70, 239, 0.6) 50%,
|
||||
rgba(236, 72, 153, 0.55) 62.5%,
|
||||
rgba(192, 132, 252, 0.5) 75%,
|
||||
rgba(126, 34, 206, 0.6) 87.5%,
|
||||
rgba(147, 51, 234, 0.5) 100%
|
||||
);
|
||||
--surface: rgb(21, 18, 24);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Installer window size */
|
||||
#bg {
|
||||
position: relative;
|
||||
width: 480px;
|
||||
height: 520px;
|
||||
overflow: hidden;
|
||||
background-color: var(--surface);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/*
|
||||
Same structure as DMG `.brand` + `.brand__logo-wrap::before`.
|
||||
Title bar is 40px in the installer; DMG brand starts at 28px under Finder chrome.
|
||||
*/
|
||||
.brand {
|
||||
position: absolute;
|
||||
top: 68px; /* 40 title + 28 brand inset */
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand__logo-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 68px;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* Exact copy of dmg-background/styles.css `.brand__logo-wrap::before` */
|
||||
.brand__logo-wrap::before {
|
||||
content: "";
|
||||
background: var(--brand-conic);
|
||||
position: absolute;
|
||||
width: 1200px;
|
||||
height: 1200px;
|
||||
bottom: -180px;
|
||||
opacity: 0.48;
|
||||
left: calc(50% - 600px);
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="bg"></div>
|
||||
<script>
|
||||
// Mount brand glow the same way as the DMG markup.
|
||||
const bg = document.getElementById("bg");
|
||||
bg.innerHTML =
|
||||
'<header class="brand"><div class="brand__logo-wrap"></div></header>';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "fromchat-installer-common"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
zstd.workspace = true
|
||||
tar.workspace = true
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
ico = "0.4"
|
||||
editpe = { version = "0.2", default-features = false, features = ["std", "images"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Registry",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Security",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_ProcessStatus",
|
||||
] }
|
||||
@@ -0,0 +1,36 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const BRANDING_PNG: &[u8] = include_bytes!("../../assets/app_window_icon.png");
|
||||
|
||||
const ICO_SIZES: [u32; 4] = [16, 32, 48, 256];
|
||||
|
||||
pub fn write_install_icon(install_dir: &Path) -> Result<PathBuf> {
|
||||
fs::create_dir_all(install_dir)?;
|
||||
let ico_path = install_dir.join("FromChat.ico");
|
||||
if !ico_path.is_file() {
|
||||
let bytes = png_bytes_to_ico(BRANDING_PNG)?;
|
||||
fs::write(&ico_path, bytes).with_context(|| format!("write {}", ico_path.display()))?;
|
||||
}
|
||||
Ok(ico_path)
|
||||
}
|
||||
|
||||
pub fn png_bytes_to_ico(png: &[u8]) -> Result<Vec<u8>> {
|
||||
let image = image::load_from_memory(png).context("decode branding png")?;
|
||||
let mut icon_dir = ico::IconDir::new(ico::ResourceType::Icon);
|
||||
for size in ICO_SIZES {
|
||||
let resized = image.resize_exact(size, size, image::imageops::FilterType::Lanczos3);
|
||||
let rgba = resized.to_rgba8();
|
||||
let entry = ico::IconDirEntry::encode(&ico::IconImage::from_rgba_data(
|
||||
size,
|
||||
size,
|
||||
rgba.into_raw(),
|
||||
))
|
||||
.context("encode icon entry")?;
|
||||
icon_dir.add_entry(entry);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
icon_dir.write(&mut out).context("encode ico")?;
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use crate::BRANDING_PNG;
|
||||
use anyhow::{Context, Result};
|
||||
use editpe::{Image, ResourceEntryName};
|
||||
use std::path::Path;
|
||||
|
||||
/// `RT_ICON` / `RT_GROUP_ICON` — Windows uses the first numeric group as the Task Manager icon.
|
||||
const RT_ICON: u32 = 3;
|
||||
const RT_GROUP_ICON: u32 = 14;
|
||||
|
||||
/// Embeds the branded app icon into a jpackage `FromChat.exe` (Task Manager / Alt+Tab).
|
||||
///
|
||||
/// jpackage leaves the default Java coffee-cup as group icon ID 1. `set_main_icon` only
|
||||
/// adds a named `MAINICON` group and keeps that Java icon, so Task Manager still shows it.
|
||||
/// Strip every icon resource first, then write ours as the only group.
|
||||
pub fn patch_jpackage_exe_icon(exe_path: &Path) -> Result<()> {
|
||||
let mut image = Image::parse_file(exe_path)
|
||||
.with_context(|| format!("parse {}", exe_path.display()))?;
|
||||
let mut resources = image.resource_directory().cloned().unwrap_or_default();
|
||||
resources.root_mut().remove(ResourceEntryName::ID(RT_ICON));
|
||||
resources.root_mut().remove(ResourceEntryName::ID(RT_GROUP_ICON));
|
||||
let tmp = std::env::temp_dir().join("fromchat-branding.png");
|
||||
std::fs::write(&tmp, BRANDING_PNG).context("write temp branding png")?;
|
||||
resources
|
||||
.set_main_icon_file(
|
||||
tmp.to_str()
|
||||
.context("temp icon path is not valid UTF-8")?,
|
||||
)
|
||||
.context("set main icon on jpackage exe")?;
|
||||
image
|
||||
.set_resource_directory(resources)
|
||||
.context("apply icon resources to jpackage exe")?;
|
||||
let temp = exe_path.with_extension("icon-patch.tmp.exe");
|
||||
if temp.is_file() {
|
||||
std::fs::remove_file(&temp).ok();
|
||||
}
|
||||
image
|
||||
.write_file(&temp)
|
||||
.with_context(|| format!("write temp {}", temp.display()))?;
|
||||
replace_file(&temp, exe_path)
|
||||
.with_context(|| format!("replace {}", exe_path.display()))?;
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_file(src: &Path, dest: &Path) -> Result<()> {
|
||||
if dest.is_file() {
|
||||
let mut permissions = std::fs::metadata(dest)
|
||||
.with_context(|| format!("stat {}", dest.display()))?
|
||||
.permissions();
|
||||
permissions.set_readonly(false);
|
||||
std::fs::set_permissions(dest, permissions)
|
||||
.with_context(|| format!("clear read-only on {}", dest.display()))?;
|
||||
std::fs::remove_file(dest)
|
||||
.with_context(|| format!("remove {}", dest.display()))?;
|
||||
}
|
||||
std::fs::rename(src, dest).with_context(|| format!("rename to {}", dest.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// RGBA8 icon pixels (width × height × 4).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IconRgba {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Parses a Windows `DisplayIcon` registry value (`path` or `path,index`).
|
||||
pub fn parse_display_icon(raw: &str) -> (PathBuf, i32) {
|
||||
let raw = raw.trim().trim_matches('"');
|
||||
if let Some((path, index_str)) = raw.rsplit_once(',') {
|
||||
if index_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
if let Ok(index) = index_str.parse::<i32>() {
|
||||
let path = path.trim().trim_matches('"');
|
||||
if !path.is_empty() {
|
||||
return (PathBuf::from(path), index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(PathBuf::from(raw), 0)
|
||||
}
|
||||
|
||||
/// Loads the install icon referenced by registry, with optional install-dir fallbacks.
|
||||
pub fn load_install_display_icon(
|
||||
display_icon: Option<&str>,
|
||||
install_dir: &Path,
|
||||
) -> Option<IconRgba> {
|
||||
if let Some(raw) = display_icon.filter(|s| !s.is_empty()) {
|
||||
let (path, _index) = parse_display_icon(raw);
|
||||
if let Some(icon) = try_load_icon_path(&path) {
|
||||
return Some(icon);
|
||||
}
|
||||
}
|
||||
let ico = install_dir.join("FromChat.ico");
|
||||
if ico.is_file() {
|
||||
return load_ico_file(&ico);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn try_load_icon_path(path: &Path) -> Option<IconRgba> {
|
||||
if !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or_default();
|
||||
if ext.eq_ignore_ascii_case("ico") {
|
||||
return load_ico_file(path);
|
||||
}
|
||||
if ext.eq_ignore_ascii_case("exe") {
|
||||
let ico = path.with_extension("ico");
|
||||
if ico.is_file() {
|
||||
return load_ico_file(&ico);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn load_ico_file(path: &Path) -> Option<IconRgba> {
|
||||
let data = std::fs::read(path).ok()?;
|
||||
let dir = ico::IconDir::read(&mut Cursor::new(data)).ok()?;
|
||||
let target = 40u32;
|
||||
let entry = dir
|
||||
.entries()
|
||||
.iter()
|
||||
.min_by_key(|entry| {
|
||||
let w = u32::from(entry.width());
|
||||
let h = u32::from(entry.height());
|
||||
w.abs_diff(target) + h.abs_diff(target)
|
||||
})?;
|
||||
let image = entry.decode().ok()?;
|
||||
let width = u32::from(image.width());
|
||||
let height = u32::from(image.height());
|
||||
let rgba = image.rgba_data().to_vec();
|
||||
Some(IconRgba {
|
||||
width,
|
||||
height,
|
||||
pixels: rgba,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use crate::payload::HIDDEN_APP_EXE;
|
||||
use crate::FromChatEdition;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// User-visible launcher in the install root (has the embedded app icon).
|
||||
pub const VISIBLE_BETA_EXE: &str = "FromChat Beta.exe";
|
||||
|
||||
/// Legacy beta launcher name removed on upgrade.
|
||||
const LEGACY_VISIBLE_BETA_EXE: &str = "FromChat (Beta).exe";
|
||||
|
||||
/// Visible launcher name for an edition, if different from [HIDDEN_APP_EXE].
|
||||
pub fn visible_launcher_name(edition: FromChatEdition) -> Option<&'static str> {
|
||||
match edition {
|
||||
FromChatEdition::Release => None,
|
||||
FromChatEdition::Beta => Some(VISIBLE_BETA_EXE),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the path users should run (visible beta launcher or jpackage exe).
|
||||
#[cfg(windows)]
|
||||
pub fn finalize_install_launcher(
|
||||
dest: &Path,
|
||||
edition: FromChatEdition,
|
||||
launcher_bytes: &[u8],
|
||||
) -> Result<PathBuf> {
|
||||
let hidden = find_jpackage_app_exe(dest)?;
|
||||
if let Some(visible_name) = visible_launcher_name(edition) {
|
||||
let visible = dest.join(visible_name);
|
||||
fs::write(&visible, launcher_bytes)
|
||||
.with_context(|| format!("write visible launcher {}", visible.display()))?;
|
||||
let legacy = dest.join(LEGACY_VISIBLE_BETA_EXE);
|
||||
if legacy.is_file() {
|
||||
let _ = fs::remove_file(&legacy);
|
||||
}
|
||||
crate::hide_all_except(dest, visible_name)?;
|
||||
Ok(visible)
|
||||
} else {
|
||||
Ok(hidden)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn finalize_install_launcher(
|
||||
dest: &Path,
|
||||
_edition: FromChatEdition,
|
||||
_launcher_bytes: &[u8],
|
||||
) -> Result<PathBuf> {
|
||||
find_jpackage_app_exe(dest)
|
||||
}
|
||||
|
||||
pub fn find_jpackage_app_exe(dest: &Path) -> Result<PathBuf> {
|
||||
let candidates = [
|
||||
dest.join(HIDDEN_APP_EXE),
|
||||
dest.join("bin").join(HIDDEN_APP_EXE),
|
||||
];
|
||||
for candidate in candidates {
|
||||
if candidate.is_file() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
for entry in walk_files(dest)? {
|
||||
let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if name.eq_ignore_ascii_case(HIDDEN_APP_EXE) {
|
||||
return Ok(entry);
|
||||
}
|
||||
if name.eq_ignore_ascii_case(VISIBLE_BETA_EXE) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
bail!("{HIDDEN_APP_EXE} not found under {}", dest.display());
|
||||
}
|
||||
|
||||
fn walk_files(root: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut out = Vec::new();
|
||||
fn rec(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if entry.file_type()?.is_dir() {
|
||||
rec(&path, out)?;
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
rec(root, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Shared payload format, extract, and Windows install helpers for FromChat setup.
|
||||
|
||||
mod branding;
|
||||
mod exe_icon;
|
||||
mod install_icon;
|
||||
mod install_layout;
|
||||
mod payload;
|
||||
mod progress;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod win;
|
||||
|
||||
pub use branding::*;
|
||||
pub use exe_icon::*;
|
||||
pub use install_icon::*;
|
||||
pub use install_layout::*;
|
||||
pub use payload::*;
|
||||
pub use progress::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub use win::*;
|
||||
@@ -0,0 +1,395 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const SETUP_MAGIC: &[u8; 8] = b"FCHSETUP";
|
||||
pub const SETUP_MAGIC_V2: &[u8; 8] = b"FCHSETU2";
|
||||
pub const PORTABLE_MAGIC: &[u8; 8] = b"FCHPORT1";
|
||||
pub const VISIBLE_PORTABLE_EXE: &str = "FromChat Portable.exe";
|
||||
pub const HIDDEN_APP_EXE: &str = "FromChat.exe";
|
||||
pub const SETUP_INSTALLER_EXE: &str = "FromChat-Installer.exe";
|
||||
pub const SETUP_HELPER_EXE: &str = "FromChat-Installer-Helper.exe";
|
||||
pub const PIPE_NAME: &str = r"\\.\pipe\FromChat-Installer-Helper";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BundleMeta {
|
||||
pub version: String,
|
||||
pub mode: BundleMode,
|
||||
/// Programs and Features registry id (`FromChat` or `FromChat Beta`).
|
||||
#[serde(default = "default_registration_id")]
|
||||
pub registration_id: String,
|
||||
}
|
||||
|
||||
fn default_registration_id() -> String {
|
||||
"FromChat".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BundleMode {
|
||||
Setup,
|
||||
Portable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmbeddedBundle {
|
||||
pub meta: BundleMeta,
|
||||
pub helper: Vec<u8>,
|
||||
pub launcher: Vec<u8>,
|
||||
payload_x64: Option<Vec<u8>>,
|
||||
payload_arm64: Option<Vec<u8>>,
|
||||
legacy_payload: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl EmbeddedBundle {
|
||||
pub fn payload_zstd(&self) -> Result<&[u8]> {
|
||||
if let Some(payload) = host_payload(self) {
|
||||
return Ok(payload);
|
||||
}
|
||||
bail!("installer has no payload for this CPU ({})", std::env::consts::ARCH)
|
||||
}
|
||||
}
|
||||
|
||||
fn host_payload(bundle: &EmbeddedBundle) -> Option<&[u8]> {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => bundle
|
||||
.payload_x64
|
||||
.as_deref()
|
||||
.or(bundle.legacy_payload.as_deref()),
|
||||
"aarch64" => bundle
|
||||
.payload_arm64
|
||||
.as_deref()
|
||||
.or(bundle.legacy_payload.as_deref()),
|
||||
_ => bundle.legacy_payload.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct FooterV1 {
|
||||
magic: [u8; 8],
|
||||
meta_off: u64,
|
||||
meta_len: u64,
|
||||
helper_off: u64,
|
||||
helper_len: u64,
|
||||
launcher_off: u64,
|
||||
launcher_len: u64,
|
||||
payload_off: u64,
|
||||
payload_len: u64,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct FooterV2 {
|
||||
magic: [u8; 8],
|
||||
meta_off: u64,
|
||||
meta_len: u64,
|
||||
helper_off: u64,
|
||||
helper_len: u64,
|
||||
launcher_off: u64,
|
||||
launcher_len: u64,
|
||||
payload_x64_off: u64,
|
||||
payload_x64_len: u64,
|
||||
payload_arm64_off: u64,
|
||||
payload_arm64_len: u64,
|
||||
}
|
||||
|
||||
impl FooterV1 {
|
||||
const SIZE: usize = 8 + 8 * 8;
|
||||
|
||||
fn to_bytes(self) -> [u8; Self::SIZE] {
|
||||
let mut out = [0u8; Self::SIZE];
|
||||
out[0..8].copy_from_slice(&self.magic);
|
||||
let mut i = 8;
|
||||
for v in [
|
||||
self.meta_off,
|
||||
self.meta_len,
|
||||
self.helper_off,
|
||||
self.helper_len,
|
||||
self.launcher_off,
|
||||
self.launcher_len,
|
||||
self.payload_off,
|
||||
self.payload_len,
|
||||
] {
|
||||
out[i..i + 8].copy_from_slice(&v.to_le_bytes());
|
||||
i += 8;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn from_bytes(bytes: &[u8]) -> Result<Self> {
|
||||
if bytes.len() < Self::SIZE {
|
||||
bail!("footer too short");
|
||||
}
|
||||
let mut magic = [0u8; 8];
|
||||
magic.copy_from_slice(&bytes[0..8]);
|
||||
let mut vals = [0u64; 8];
|
||||
for (idx, slot) in vals.iter_mut().enumerate() {
|
||||
let start = 8 + idx * 8;
|
||||
*slot = u64::from_le_bytes(bytes[start..start + 8].try_into().unwrap());
|
||||
}
|
||||
Ok(Self {
|
||||
magic,
|
||||
meta_off: vals[0],
|
||||
meta_len: vals[1],
|
||||
helper_off: vals[2],
|
||||
helper_len: vals[3],
|
||||
launcher_off: vals[4],
|
||||
launcher_len: vals[5],
|
||||
payload_off: vals[6],
|
||||
payload_len: vals[7],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FooterV2 {
|
||||
const SIZE: usize = 8 + 10 * 8;
|
||||
|
||||
fn to_bytes(self) -> [u8; Self::SIZE] {
|
||||
let mut out = [0u8; Self::SIZE];
|
||||
out[0..8].copy_from_slice(&self.magic);
|
||||
let mut i = 8;
|
||||
for v in [
|
||||
self.meta_off,
|
||||
self.meta_len,
|
||||
self.helper_off,
|
||||
self.helper_len,
|
||||
self.launcher_off,
|
||||
self.launcher_len,
|
||||
self.payload_x64_off,
|
||||
self.payload_x64_len,
|
||||
self.payload_arm64_off,
|
||||
self.payload_arm64_len,
|
||||
] {
|
||||
out[i..i + 8].copy_from_slice(&v.to_le_bytes());
|
||||
i += 8;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn from_bytes(bytes: &[u8]) -> Result<Self> {
|
||||
if bytes.len() < Self::SIZE {
|
||||
bail!("footer too short");
|
||||
}
|
||||
let mut magic = [0u8; 8];
|
||||
magic.copy_from_slice(&bytes[0..8]);
|
||||
let mut vals = [0u64; 10];
|
||||
for (idx, slot) in vals.iter_mut().enumerate() {
|
||||
let start = 8 + idx * 8;
|
||||
*slot = u64::from_le_bytes(bytes[start..start + 8].try_into().unwrap());
|
||||
}
|
||||
Ok(Self {
|
||||
magic,
|
||||
meta_off: vals[0],
|
||||
meta_len: vals[1],
|
||||
helper_off: vals[2],
|
||||
helper_len: vals[3],
|
||||
launcher_off: vals[4],
|
||||
launcher_len: vals[5],
|
||||
payload_x64_off: vals[6],
|
||||
payload_x64_len: vals[7],
|
||||
payload_arm64_off: vals[8],
|
||||
payload_arm64_len: vals[9],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append_bundle(
|
||||
stub: &[u8],
|
||||
magic: &[u8; 8],
|
||||
meta: &BundleMeta,
|
||||
helper: &[u8],
|
||||
launcher: &[u8],
|
||||
payload_zstd: &[u8],
|
||||
out: &Path,
|
||||
) -> Result<()> {
|
||||
let meta_bytes = serde_json::to_vec(meta)?;
|
||||
let mut file = File::create(out).with_context(|| format!("create {}", out.display()))?;
|
||||
file.write_all(stub)?;
|
||||
let base = stub.len() as u64;
|
||||
let meta_off = base;
|
||||
file.write_all(&meta_bytes)?;
|
||||
let helper_off = meta_off + meta_bytes.len() as u64;
|
||||
file.write_all(helper)?;
|
||||
let launcher_off = helper_off + helper.len() as u64;
|
||||
file.write_all(launcher)?;
|
||||
let payload_off = launcher_off + launcher.len() as u64;
|
||||
file.write_all(payload_zstd)?;
|
||||
let footer = FooterV1 {
|
||||
magic: *magic,
|
||||
meta_off,
|
||||
meta_len: meta_bytes.len() as u64,
|
||||
helper_off,
|
||||
helper_len: helper.len() as u64,
|
||||
launcher_off,
|
||||
launcher_len: launcher.len() as u64,
|
||||
payload_off,
|
||||
payload_len: payload_zstd.len() as u64,
|
||||
};
|
||||
file.write_all(&footer.to_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn append_setup_bundle_v2(
|
||||
stub: &[u8],
|
||||
meta: &BundleMeta,
|
||||
helper: &[u8],
|
||||
launcher: &[u8],
|
||||
payload_x64: Option<&[u8]>,
|
||||
payload_arm64: Option<&[u8]>,
|
||||
out: &Path,
|
||||
) -> Result<()> {
|
||||
if payload_x64.is_none() && payload_arm64.is_none() {
|
||||
bail!("at least one app payload is required");
|
||||
}
|
||||
let meta_bytes = serde_json::to_vec(meta)?;
|
||||
let mut file = File::create(out).with_context(|| format!("create {}", out.display()))?;
|
||||
file.write_all(stub)?;
|
||||
let base = stub.len() as u64;
|
||||
let meta_off = base;
|
||||
file.write_all(&meta_bytes)?;
|
||||
let helper_off = meta_off + meta_bytes.len() as u64;
|
||||
file.write_all(helper)?;
|
||||
let launcher_off = helper_off + helper.len() as u64;
|
||||
file.write_all(launcher)?;
|
||||
|
||||
let (payload_x64_off, payload_x64_len) = if let Some(payload) = payload_x64 {
|
||||
let off = launcher_off + launcher.len() as u64;
|
||||
file.write_all(payload)?;
|
||||
(off, payload.len() as u64)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
|
||||
let (payload_arm64_off, payload_arm64_len) = if let Some(payload) = payload_arm64 {
|
||||
let off = if payload_x64_len > 0 {
|
||||
payload_x64_off + payload_x64_len
|
||||
} else {
|
||||
launcher_off + launcher.len() as u64
|
||||
};
|
||||
file.write_all(payload)?;
|
||||
(off, payload.len() as u64)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
|
||||
let footer = FooterV2 {
|
||||
magic: *SETUP_MAGIC_V2,
|
||||
meta_off,
|
||||
meta_len: meta_bytes.len() as u64,
|
||||
helper_off,
|
||||
helper_len: helper.len() as u64,
|
||||
launcher_off,
|
||||
launcher_len: launcher.len() as u64,
|
||||
payload_x64_off,
|
||||
payload_x64_len,
|
||||
payload_arm64_off,
|
||||
payload_arm64_len,
|
||||
};
|
||||
file.write_all(&footer.to_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_bundle_from_exe(exe: &Path, expected_magic: &[u8; 8]) -> Result<EmbeddedBundle> {
|
||||
let mut file = File::open(exe).with_context(|| format!("open {}", exe.display()))?;
|
||||
let len = file.metadata()?.len();
|
||||
if len < FooterV1::SIZE as u64 {
|
||||
bail!("executable too small to contain a bundle");
|
||||
}
|
||||
|
||||
file.seek(SeekFrom::End(-(FooterV2::SIZE as i64)))?;
|
||||
let mut footer_v2_buf = [0u8; FooterV2::SIZE];
|
||||
file.read_exact(&mut footer_v2_buf)?;
|
||||
if &footer_v2_buf[0..8] == SETUP_MAGIC_V2 && expected_magic == SETUP_MAGIC {
|
||||
let footer = FooterV2::from_bytes(&footer_v2_buf)?;
|
||||
return read_bundle_v2(&mut file, footer);
|
||||
}
|
||||
|
||||
file.seek(SeekFrom::End(-(FooterV1::SIZE as i64)))?;
|
||||
let mut footer_v1_buf = [0u8; FooterV1::SIZE];
|
||||
file.read_exact(&mut footer_v1_buf)?;
|
||||
let footer = FooterV1::from_bytes(&footer_v1_buf)?;
|
||||
if &footer.magic != expected_magic {
|
||||
bail!("bundle magic mismatch");
|
||||
}
|
||||
read_bundle_v1(&mut file, footer)
|
||||
}
|
||||
|
||||
fn read_bundle_v1(file: &mut File, footer: FooterV1) -> Result<EmbeddedBundle> {
|
||||
let mut meta = vec![0u8; footer.meta_len as usize];
|
||||
file.seek(SeekFrom::Start(footer.meta_off))?;
|
||||
file.read_exact(&mut meta)?;
|
||||
let mut helper = vec![0u8; footer.helper_len as usize];
|
||||
file.seek(SeekFrom::Start(footer.helper_off))?;
|
||||
file.read_exact(&mut helper)?;
|
||||
let mut launcher = vec![0u8; footer.launcher_len as usize];
|
||||
file.seek(SeekFrom::Start(footer.launcher_off))?;
|
||||
file.read_exact(&mut launcher)?;
|
||||
let mut payload_zstd = vec![0u8; footer.payload_len as usize];
|
||||
file.seek(SeekFrom::Start(footer.payload_off))?;
|
||||
file.read_exact(&mut payload_zstd)?;
|
||||
Ok(EmbeddedBundle {
|
||||
meta: serde_json::from_slice(&meta)?,
|
||||
helper,
|
||||
launcher,
|
||||
payload_x64: None,
|
||||
payload_arm64: None,
|
||||
legacy_payload: Some(payload_zstd),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_bundle_v2(file: &mut File, footer: FooterV2) -> Result<EmbeddedBundle> {
|
||||
let mut meta = vec![0u8; footer.meta_len as usize];
|
||||
file.seek(SeekFrom::Start(footer.meta_off))?;
|
||||
file.read_exact(&mut meta)?;
|
||||
let mut helper = vec![0u8; footer.helper_len as usize];
|
||||
file.seek(SeekFrom::Start(footer.helper_off))?;
|
||||
file.read_exact(&mut helper)?;
|
||||
let mut launcher = vec![0u8; footer.launcher_len as usize];
|
||||
file.seek(SeekFrom::Start(footer.launcher_off))?;
|
||||
file.read_exact(&mut launcher)?;
|
||||
|
||||
let payload_x64 = read_optional_payload(file, footer.payload_x64_off, footer.payload_x64_len)?;
|
||||
let payload_arm64 =
|
||||
read_optional_payload(file, footer.payload_arm64_off, footer.payload_arm64_len)?;
|
||||
|
||||
Ok(EmbeddedBundle {
|
||||
meta: serde_json::from_slice(&meta)?,
|
||||
helper,
|
||||
launcher,
|
||||
payload_x64,
|
||||
payload_arm64,
|
||||
legacy_payload: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_optional_payload(file: &mut File, offset: u64, len: u64) -> Result<Option<Vec<u8>>> {
|
||||
if len == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut payload = vec![0u8; len as usize];
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
file.read_exact(&mut payload)?;
|
||||
Ok(Some(payload))
|
||||
}
|
||||
|
||||
pub fn compress_directory_zstd(dir: &Path) -> Result<Vec<u8>> {
|
||||
let mut builder = tar::Builder::new(Vec::new());
|
||||
builder.append_dir_all(".", dir)?;
|
||||
let tar_bytes = builder.into_inner()?;
|
||||
Ok(zstd::encode_all(&tar_bytes[..], 10)?)
|
||||
}
|
||||
|
||||
pub fn extract_zstd_tar(payload_zstd: &[u8], dest: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dest)?;
|
||||
let tar_bytes = zstd::decode_all(payload_zstd)?;
|
||||
let mut archive = tar::Archive::new(&tar_bytes[..]);
|
||||
archive.unpack(dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn current_exe() -> Result<PathBuf> {
|
||||
Ok(std::env::current_exe()?)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user