77 Commits

426 changed files with 35699 additions and 6795 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"name": "FromChat",
"user": "ubuntu",
"install": "bash .cursor/scripts/cloud-setup.sh"
}
+156
View File
@@ -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
@@ -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
+30
View File
@@ -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
-11
View File
@@ -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"
+66
View File
@@ -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
+558
View File
@@ -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
View File
@@ -31,4 +31,9 @@ google-services.json
*.log
premortem-transcript-*.md
premortem-report-*.html
premortem-report-*.html
target/
node_modules
preview-dist
+8
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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" />
+7
View File
@@ -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>
+7 -1
View File
@@ -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
View File
@@ -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.
+37 -17
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
- Наконец-то появилась десктопная версия FromChat! Теперь вы можете общаться прямо с вашего компьютера на Windows, Linux и macOS. Даже старые Mac на Intel поддерживаются.
- На Android и десктопе вы теперь можете напрямую перетаскивать файлы и картинки прямо в чат без необходимости долго их искать.
- Улучшен экран устройств: теперь более наглядно видно, на каких устройствах вы вошли в аккаунт. И иконки теперь везде одинаковые.
+45 -26
View File
@@ -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")
}
+7
View File
@@ -1,6 +1,13 @@
# Release shrinking: obfuscate/rename classes and members as aggressively as R8 allows.
# proguard-android-optimize.txt (from build.gradle) supplies repackageclasses, overloadaggressively, etc.
# ru.fromchat: keep names for plugin hooks; allow unused code removal.
-keepnames,allowshrinking class ru.fromchat.** { *; }
-keepclassmembernames,allowshrinking class ru.fromchat.** { *; }
# Plugin SDK loaded from plugin artifacts.
-keepnames class ru.fromchat.plugins.** { *; }
# Crash reports: keep real .kt file names and line numbers; class names stay obfuscated.
-keepattributes SourceFile,LineNumberTable
+3
View File
@@ -22,6 +22,9 @@
<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"
+6 -112
View File
@@ -2,132 +2,26 @@ 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
import ru.fromchat.plugins.integration.AndroidPluginBootstrap
class App : Application() {
@OptIn(DelicateCoroutinesApi::class)
override fun onCreate() {
super.onCreate()
UtilsLibrary.init(this)
ru.fromchat.notifications.ChatNotificationDismissals.install(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()
AndroidPluginBootstrap.init(this)
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)
@@ -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,20 +27,11 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
val fallbackMessageId = pushData["message_id"]?.toIntOrNull()
?: pushData["dm_id"]?.toIntOrNull()
val senderId = pushData["sender_id"]?.toIntOrNull()
val sender = pushData["sender_display_name"]
?.takeIf { it.isNotBlank() }
?: pushData["sender_username"]
?: pushData["senderUsername"]
?: pushData["senderDisplayName"]
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) {
@@ -48,16 +39,12 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
return@launch
}
if (isDirectMessage) {
NotificationHelper.fetchAndNotify(
applicationContext,
MessageNotificationCoordinator.fetchAndNotify(
includeDmMessages = true,
dmMessageId = fallbackMessageId,
dmSenderName = sender,
)
} else {
// Public: one debounced /messages/new → MessagingStyle. Never post a
// per-message fallback (that duplicated FCM tray entries with different labels).
NotificationHelper.schedulePublicFetchAndNotify(applicationContext)
MessageNotificationCoordinator.schedulePublicFetchAndNotify()
}
} catch (e: Exception) {
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
@@ -65,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,657 +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.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.MainActivity
import ru.fromchat.Logger
import ru.fromchat.R
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache
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 GROUP_PUBLIC = "ru.fromchat.notifications.public"
private const val GROUP_DM_PREFIX = "ru.fromchat.notifications.dm."
private const val SUMMARY_NOTIFICATION_ID = 1000000
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"
private const val PUBLIC_FETCH_DEBOUNCE_MS = 450L
const val KEY_TEXT_REPLY = "key_text_reply"
private val helperScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val publicFetchMutex = Mutex()
private var publicFetchJob: Job? = null
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
private fun publicConversationTitle(context: Context): String =
PublicChatProfileCache.profile?.title?.takeIf { it.isNotBlank() }
?: runCatching {
PublicChatProfileCache.hydrateFromDiskImmediate(
CacheContext.activeInstanceId.value.trim()
)?.title?.takeIf { it.isNotBlank() }
}.getOrNull()
?: context.getString(R.string.public_chat)
private fun senderDisplayLabel(message: Message, currentUserId: Int): String {
ProfileCache.get(message.user_id)
?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
?.let { return it }
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
return message.username.trim().ifBlank { "FromChat" }
}
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"
}
)
}
}
/** Coalesce rapid public FCM wakes into one /messages/new → MessagingStyle refresh. */
fun schedulePublicFetchAndNotify(context: Context) {
publicFetchJob?.cancel()
publicFetchJob = helperScope.launch {
delay(PUBLIC_FETCH_DEBOUNCE_MS)
publicFetchMutex.withLock {
fetchAndNotify(context.applicationContext, includeDmMessages = false)
}
}
}
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())
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()) {
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.senderDisplayName.isNullOrBlank() -> envelope.senderDisplayName
else -> ProfileCache.get(envelope.senderId)
?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
?: envelope.senderUsername
}.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()) {
context.getString(R.string.notification_direct_message_from, senderName)
} else {
context.getString(R.string.notification_direct_message)
},
body = notificationBody,
sender = senderName,
messageId = envelopeId,
allowWhenPublicChatVisible = true,
isDirectMessage = true,
targetDmUserId = dmConversationUserId,
conversationTitle = context.getString(R.string.notification_direct_messages_title)
)
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 = context.getString(R.string.public_chat),
senderId: Int? = null,
) {
helperScope.launch(Dispatchers.Main) {
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"
val groupKey = if (isDirectMessage && targetDmUserId != null) {
GROUP_DM_PREFIX + targetDmUserId
} else {
GROUP_PUBLIC
}
val notificationId = if (isDirectMessage && targetDmUserId != null) {
SUMMARY_NOTIFICATION_ID + targetDmUserId
} else {
SUMMARY_NOTIFICATION_ID
}
cancelStaleSystemTrayDuplicates(context)
notify(
notificationId,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(NotificationSmallIcon.resId(context))
.setContentTitle(title)
.setContentText(body)
.setGroup(groupKey)
.setStyle(
NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build()
)
.setConversationTitle(conversationTitle)
.setGroupConversation(true)
.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,
context.getString(R.string.notification_reply),
createReplyIntent(
context = context,
isDirectMessage = isDirectMessage,
targetDmUserId = targetDmUserId,
parentMessageId = messageId
)
)
.addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel(context.getString(R.string.notification_reply_hint))
.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"
)
}
}
}
private fun displayNotifications(context: Context, messages: List<Message>) {
Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages")
if (isPublicChatVisible) {
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
return
}
helperScope.launch(Dispatchers.Main.immediate) {
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
var newMessageCount = 0
val previewStrings = listPreviewStrings(context)
val conversationTitle = publicConversationTitle(context)
val avatar = PublicChatNotificationAvatar.create(conversationTitle)
with(NotificationManagerCompat.from(context)) {
if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
Logger.w(
"NotificationHelper",
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
)
return@launch
}
val currentUserId = settings.getInt("current_user_id", -1)
if (currentUserId == -1) return@launch
val newMessages = messages
.filter { msg ->
!shown.contains(msg.id.toString()) && msg.user_id != currentUserId
}
.sortedBy { it.id }
if (newMessages.isEmpty()) {
Logger.d(
"NotificationHelper",
"displayNotifications: no new messages after filters for user=$currentUserId"
)
return@launch
}
newMessages.forEach { shown.add(it.id.toString()) }
newMessageCount = newMessages.size
Logger.d(
"NotificationHelper",
"displayNotifications: user=$currentUserId totalMessages=${messages.size} " +
"newMessages=$newMessageCount conversationTitle=$conversationTitle"
)
createChannel(context)
cancelStaleSystemTrayDuplicates(context)
val messagingStyle = NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build()
)
.setConversationTitle(conversationTitle)
.setGroupConversation(true)
for (msg in newMessages.takeLast(10)) {
val timestamp = try {
Instant.parse(msg.timestamp).toEpochMilliseconds()
} catch (_: Exception) {
System.currentTimeMillis()
}
messagingStyle.addMessage(
NotificationCompat.MessagingStyle.Message(
notificationBodyForMessage(msg, previewStrings),
timestamp,
Person.Builder()
.setName(senderDisplayLabel(msg, currentUserId))
.setKey(msg.user_id.toString())
.build()
)
)
}
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(NotificationSmallIcon.resId(context))
.setLargeIcon(avatar)
.setContentTitle(conversationTitle)
.setStyle(messagingStyle)
.setGroup(GROUP_PUBLIC)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.addAction(
NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send,
context.getString(R.string.notification_reply),
createReplyIntent(
context = context,
isDirectMessage = false,
parentMessageId = newMessages.last().id
)
)
.addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel(context.getString(R.string.notification_reply_hint))
.build()
)
.setAllowGeneratedReplies(true)
.build()
)
.setContentIntent(createMessageIntent(context, newMessages.last().id))
.setShortcutId(GROUP_PUBLIC)
.build()
)
}
settings.putStringSet(PREF_SHOWN_KEY, shown)
Logger.i(
"NotificationHelper",
"displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}"
)
}
}
/** Clears FCM auto-posted tray entries (notification payload) that duplicate our MessagingStyle. */
private fun cancelStaleSystemTrayDuplicates(context: Context) {
runCatching {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Legacy FCM auto notifications used id 0 / fcm_fallback_notification_channel.
manager.cancel(0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
manager.activeNotifications
.filter { status ->
status.notification.channelId == "fcm_fallback_notification_channel" ||
status.id == 0
}
.forEach { status ->
manager.cancel(status.tag, status.id)
}
}
}
}
}
@@ -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 {
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

@@ -0,0 +1,8 @@
{
"appLabel": "FromChat",
"applicationsLabel": "Программы",
"primaryColor": "#dabaf9",
"titleBarInset": 22,
"windowChromeBottom": 22
}
+318
View File
@@ -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);
});
+48
View File
@@ -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
View File
@@ -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"
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "fromchat-dmg-background-tools",
"private": true,
"type": "module",
"dependencies": {
"playwright": "1.52.0"
}
}
+261
View File
@@ -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

+41
View File
@@ -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)
*
* Listdetail 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);
}
+1
View File
@@ -0,0 +1 @@
target/
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
# Программа установки FromChat
*Честно признаюсь, это 100% вайбкод. Rust я не знаю. Просьба не предьявлять претензии по этому поводу.*
Это программа для установки FromChat на Windows. У нее есть 2 режима: портативная версия (распакует в папку) и полная (с регистрацией, ярлыком на рабочем столе и т.д.).
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

@@ -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()?)
}
@@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ProgressEvent {
Status { message: String },
Progress { fraction: f32 },
Done { launch_path: String },
Uninstalled,
Error { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum HelperCommand {
Install {
dest: String,
version: String,
all_users: bool,
edition: String,
start_menu: bool,
desktop: bool,
payload_path: String,
launcher_path: String,
uninstaller_path: String,
setup_exe_path: String,
},
Uninstall {
install_dir: String,
all_users: bool,
edition: String,
preserve_setup_exe: Option<String>,
preserve_user_data: bool,
},
Upgrade {
dest: String,
version: String,
all_users: bool,
edition: String,
payload_path: String,
launcher_path: String,
setup_exe_path: String,
desktop_shortcut: bool,
},
Ping,
}
+724
View File
@@ -0,0 +1,724 @@
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
use windows::core::{Interface, PCWSTR};
use windows::Win32::Foundation::{CloseHandle, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE};
use windows::Win32::Security::{
GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
};
use windows::Win32::System::Threading::{
GetCurrentProcess, GetCurrentProcessId, OpenProcess, OpenProcessToken, CREATE_NO_WINDOW,
PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, TerminateProcess,
};
use windows::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
};
use windows::Win32::System::Threading::QueryFullProcessImageNameW;
use windows::Win32::Storage::FileSystem::{
CreateFileW, FlushFileBuffers, ReadFile, SetFileAttributesW, WriteFile, FILE_ATTRIBUTE_HIDDEN,
FILE_ATTRIBUTE_NORMAL, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE,
OPEN_EXISTING,
};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoTaskMemFree, CoUninitialize, CLSCTX_INPROC_SERVER,
COINIT_APARTMENTTHREADED, IPersistFile,
};
use windows::Win32::System::Pipes::{
ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_READMODE_BYTE, PIPE_TYPE_BYTE, PIPE_WAIT,
};
use windows::Win32::System::Registry::{
RegCloseKey, RegCreateKeyExW, RegDeleteTreeW, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW,
HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WRITE, REG_CREATE_KEY_DISPOSITION,
REG_DWORD, REG_OPTION_NON_VOLATILE, REG_SZ, REG_VALUE_TYPE, HKEY,
};
use windows::Win32::UI::Shell::{
IShellLinkW, ShellExecuteW, FOLDERID_CommonPrograms, FOLDERID_Desktop, FOLDERID_Programs,
SHGetKnownFolderPath, KF_FLAG_DEFAULT,
};
use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
/// Copied setup binary in the install folder (Control Panel uninstaller).
pub const UNINSTALL_SETUP_EXE: &str = "Uninstall FromChat.exe";
/// CLI flag: skip welcome and open the uninstall confirmation screen.
pub const UNINSTALL_ARG: &str = "--uninstall";
/// CLI flag: silently upgrade the installed copy for this installer's registration id.
pub const UPGRADE_ARG: &str = "--upgrade";
/// CLI flag: launch the app when setup finishes (skips the final screen).
pub const LAUNCH_ARG: &str = "--launch";
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// Release vs beta uninstall registry entries (at most one of each per machine).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FromChatEdition {
Release,
Beta,
}
impl FromChatEdition {
pub fn registry_key(&self) -> &'static str {
match self {
Self::Release => "FromChat",
Self::Beta => "FromChat Beta",
}
}
pub fn display_name(&self) -> &'static str {
self.registry_key()
}
pub fn shortcut_name(&self) -> &'static str {
self.display_name()
}
/// Default install directory leaf (`FromChat` or `FromChat Beta`).
pub fn install_folder_name(&self) -> &'static str {
self.display_name()
}
pub fn from_registry_key(key: &str) -> Option<Self> {
if key.eq_ignore_ascii_case("FromChat") {
Some(Self::Release)
} else if key.eq_ignore_ascii_case("FromChat Beta") {
Some(Self::Beta)
} else {
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledFromChat {
pub install_dir: PathBuf,
pub all_users: bool,
pub version: Option<String>,
pub edition: FromChatEdition,
pub display_icon: Option<String>,
}
const KNOWN_REGISTRY_KEYS: &[&str] = &["FromChat", "FromChat Beta"];
pub fn uninstall_setup_path(install_dir: &Path) -> PathBuf {
install_dir.join(UNINSTALL_SETUP_EXE)
}
pub fn uninstall_command_line(uninstaller: &Path) -> String {
format!("\"{}\" {}", uninstaller.to_string_lossy(), UNINSTALL_ARG)
}
pub fn copy_uninstall_setup(setup_exe: &Path, install_dir: &Path) -> Result<PathBuf> {
let dest = uninstall_setup_path(install_dir);
fs::copy(setup_exe, &dest)
.with_context(|| format!("copy setup to {}", dest.display()))?;
Ok(dest)
}
pub fn directory_size_bytes(root: &Path) -> Result<u64> {
if !root.is_dir() {
return Ok(0);
}
let mut total = 0u64;
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
total = total.saturating_add(directory_size_bytes(&path)?);
} else {
total = total.saturating_add(entry.metadata()?.len());
}
}
Ok(total)
}
pub fn detect_all_installed() -> Vec<InstalledFromChat> {
let mut out = Vec::new();
for &registry_key in KNOWN_REGISTRY_KEYS {
if let Some(installed) = read_install_key(HKEY_LOCAL_MACHINE, true, registry_key) {
push_unique_install(&mut out, installed);
}
if let Some(installed) = read_install_key(HKEY_CURRENT_USER, false, registry_key) {
push_unique_install(&mut out, installed);
}
}
out.truncate(2);
out
}
pub fn detect_installed() -> Option<InstalledFromChat> {
detect_all_installed().into_iter().next()
}
/// Looks up a single install matching the uninstall registry id for this installer build.
pub fn find_installed_by_registration_id(registration_id: &str) -> Option<InstalledFromChat> {
if FromChatEdition::from_registry_key(registration_id).is_none() {
return None;
}
read_install_key(HKEY_LOCAL_MACHINE, true, registration_id)
.or_else(|| read_install_key(HKEY_CURRENT_USER, false, registration_id))
}
fn push_unique_install(out: &mut Vec<InstalledFromChat>, installed: InstalledFromChat) {
if out
.iter()
.any(|entry| entry.install_dir == installed.install_dir && entry.edition == installed.edition)
{
return;
}
out.push(installed);
}
fn read_install_key(root: HKEY, all_users: bool, registry_key: &str) -> Option<InstalledFromChat> {
let edition = FromChatEdition::from_registry_key(registry_key)?;
unsafe {
let sub = wide(&format!(
r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{registry_key}"
));
let mut key = HKEY::default();
RegOpenKeyExW(root, PCWSTR(sub.as_ptr()), 0, KEY_READ, &mut key)
.ok()
.ok()?;
let install_dir = read_reg_string(key, "InstallLocation").ok()?;
let version = read_reg_string(key, "DisplayVersion").ok();
let display_icon = read_reg_string(key, "DisplayIcon").ok();
let _ = RegCloseKey(key).ok();
if install_dir.is_empty() {
return None;
}
let install_dir = PathBuf::from(install_dir);
if !install_dir.is_dir() {
return None;
}
Some(InstalledFromChat {
install_dir,
all_users,
version,
edition,
display_icon,
})
}
}
pub fn uninstall_fromchat(
installed: &InstalledFromChat,
preserve_exe: Option<&Path>,
preserve_user_data: bool,
) -> Result<()> {
init_com()?;
terminate_processes_in_install_dir(&installed.install_dir)?;
let shortcut = format!("{}.lnk", installed.edition.shortcut_name());
let programs = programs_folder(installed.all_users)?;
let _ = fs::remove_file(programs.join(&shortcut));
let desk = desktop_folder()?;
let _ = fs::remove_file(desk.join(&shortcut));
delete_uninstall_registry(installed.all_users, installed.edition.registry_key())?;
if installed.install_dir.is_dir() {
let auto_preserve = std::env::current_exe()
.ok()
.filter(|exe| exe.starts_with(&installed.install_dir));
let effective_preserve = preserve_exe.or(auto_preserve.as_deref());
remove_install_dir_best_effort(&installed.install_dir, effective_preserve)?;
if installed.install_dir.exists() {
schedule_remove_dir_after_exit(&installed.install_dir)?;
}
}
if !preserve_user_data {
clear_user_data(&installed.install_dir)?;
}
Ok(())
}
/// Wipes session, settings, cache, and portable data for this installation.
pub fn clear_user_data(install_dir: &Path) -> Result<()> {
let portable_data = install_dir.join("fromchat-data");
if portable_data.is_dir() {
let _ = fs::remove_dir_all(&portable_data);
}
if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
for name in ["FromChat", "FromChat Beta"] {
let app_data = PathBuf::from(&local_app_data).join(name);
if app_data.is_dir() {
let _ = fs::remove_dir_all(&app_data);
}
}
}
clear_java_prefs_node(r"Software\JavaSoft\Prefs\ru\fromchat\settings")?;
clear_java_prefs_node(r"Software\JavaSoft\Prefs\ru\fromchat\secure")?;
Ok(())
}
fn clear_java_prefs_node(subkey: &str) -> Result<()> {
let sub = wide(subkey);
unsafe {
let _ = RegDeleteTreeW(HKEY_CURRENT_USER, PCWSTR(sub.as_ptr()));
}
Ok(())
}
pub fn wipe_install_dir(install_dir: &Path, preserve_exe: Option<&Path>) -> Result<()> {
terminate_processes_in_install_dir(install_dir)?;
if install_dir.is_dir() {
remove_install_dir_best_effort(install_dir, preserve_exe)?;
}
Ok(())
}
fn remove_install_dir_best_effort(install_dir: &Path, preserve_exe: Option<&Path>) -> Result<()> {
for entry in fs::read_dir(install_dir)? {
let entry = entry?;
let path = entry.path();
if preserve_exe.is_some_and(|exe| exe == path) {
continue;
}
if entry.file_type()?.is_dir() {
let _ = fs::remove_dir_all(&path);
} else {
let _ = fs::remove_file(&path);
}
}
Ok(())
}
fn init_com() -> Result<()> {
unsafe {
CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok()?;
}
Ok(())
}
pub fn terminate_processes_in_install_dir(install_dir: &Path) -> Result<()> {
let install_dir = normalize_path(install_dir);
let self_pid = unsafe { GetCurrentProcessId() };
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)? };
let mut entry = PROCESSENTRY32W {
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
..Default::default()
};
let mut found = unsafe { Process32FirstW(snapshot, &mut entry).is_ok() };
while found {
let pid = entry.th32ProcessID;
if pid != self_pid {
if let Ok(image) = process_image_path(pid) {
let image = normalize_path(&image);
if image.starts_with(&install_dir) {
let _ = terminate_process(pid);
}
}
}
found = unsafe { Process32NextW(snapshot, &mut entry).is_ok() };
}
unsafe {
let _ = CloseHandle(snapshot);
}
std::thread::sleep(std::time::Duration::from_millis(400));
Ok(())
}
fn process_image_path(pid: u32) -> Result<PathBuf> {
unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid)?;
let mut buf = vec![0u16; 32_768];
let mut size = buf.len() as u32;
QueryFullProcessImageNameW(
handle,
windows::Win32::System::Threading::PROCESS_NAME_WIN32,
windows::core::PWSTR(buf.as_mut_ptr()),
&mut size,
)?;
let _ = CloseHandle(handle);
let path = String::from_utf16_lossy(&buf[..size as usize]);
Ok(PathBuf::from(path))
}
}
fn terminate_process(pid: u32) -> Result<()> {
unsafe {
let handle = OpenProcess(PROCESS_TERMINATE, false, pid)?;
TerminateProcess(handle, 1)?;
let _ = CloseHandle(handle);
}
Ok(())
}
fn normalize_path(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn schedule_remove_dir_after_exit(install_dir: &Path) -> Result<()> {
let dir = install_dir.to_string_lossy().replace('\"', "");
let script = format!("timeout /t 2 /nobreak > nul & rd /s /q \"{dir}\"");
std::process::Command::new("cmd")
.args(["/C", &script])
.creation_flags(CREATE_NO_WINDOW.0)
.spawn()
.context("schedule install dir removal")?;
Ok(())
}
pub fn set_hidden(path: &Path, hidden: bool) -> Result<()> {
let w = wide(&path.to_string_lossy());
let attr = if hidden {
FILE_FLAGS_AND_ATTRIBUTES(FILE_ATTRIBUTE_HIDDEN.0)
} else {
FILE_FLAGS_AND_ATTRIBUTES(FILE_ATTRIBUTE_NORMAL.0)
};
unsafe {
SetFileAttributesW(PCWSTR(w.as_ptr()), attr)
.ok()
.with_context(|| format!("SetFileAttributes {}", path.display()))?;
}
Ok(())
}
pub fn hide_all_except(root: &Path, visible_name: &str) -> Result<()> {
for entry in fs::read_dir(root)? {
let entry = entry?;
let name_str = entry.file_name().to_string_lossy().into_owned();
if name_str.eq_ignore_ascii_case(visible_name) {
set_hidden(&entry.path(), false)?;
continue;
}
set_hidden(&entry.path(), true)?;
if entry.file_type()?.is_dir() {
hide_tree(&entry.path())?;
}
}
Ok(())
}
fn hide_tree(dir: &Path) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
set_hidden(&entry.path(), true)?;
if entry.file_type()?.is_dir() {
hide_tree(&entry.path())?;
}
}
Ok(())
}
pub fn known_folder(id: windows::core::GUID) -> Result<PathBuf> {
unsafe {
let pwstr = SHGetKnownFolderPath(&id, KF_FLAG_DEFAULT, None)?;
let path = pwstr.to_string()?;
CoTaskMemFree(Some(pwstr.0 as *const _ as *mut _));
Ok(PathBuf::from(path))
}
}
pub fn create_shortcut(
link_path: &Path,
target: &Path,
working_dir: &Path,
description: &str,
icon: &Path,
) -> Result<()> {
unsafe {
CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok()?;
// CLSID_ShellLink
let clsid = windows::core::GUID::from_u128(0x0002_1401_0000_0000_C000_000000000046);
let link: IShellLinkW = CoCreateInstance(&clsid, None, CLSCTX_INPROC_SERVER)?;
let target_w = wide(&target.to_string_lossy());
let work_w = wide(&working_dir.to_string_lossy());
let desc_w = wide(description);
let icon_w = wide(&icon.to_string_lossy());
link.SetPath(PCWSTR(target_w.as_ptr()))?;
link.SetWorkingDirectory(PCWSTR(work_w.as_ptr()))?;
link.SetDescription(PCWSTR(desc_w.as_ptr()))?;
link.SetIconLocation(PCWSTR(icon_w.as_ptr()), 0)?;
let persist: IPersistFile = link.cast()?;
let link_w = wide(&link_path.to_string_lossy());
persist.Save(PCWSTR(link_w.as_ptr()), true)?;
CoUninitialize();
}
Ok(())
}
pub fn write_uninstall_registry(
all_users: bool,
edition: FromChatEdition,
version: &str,
install_dir: &Path,
uninstall_exe: &Path,
display_icon: &Path,
) -> Result<()> {
let root = if all_users {
HKEY_LOCAL_MACHINE
} else {
HKEY_CURRENT_USER
};
let sub = wide(&format!(
r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
edition.registry_key()
));
unsafe {
let mut key = HKEY::default();
let mut disposition = REG_CREATE_KEY_DISPOSITION::default();
RegCreateKeyExW(
root,
PCWSTR(sub.as_ptr()),
0,
None,
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
None,
&mut key,
Some(&mut disposition),
)
.ok()?;
set_reg_string(key, "DisplayName", edition.display_name())?;
set_reg_string(key, "DisplayVersion", version)?;
set_reg_string(key, "Publisher", "FromChat")?;
set_reg_string(key, "InstallLocation", &install_dir.to_string_lossy())?;
set_reg_string(key, "DisplayIcon", &display_icon.to_string_lossy())?;
set_reg_string(
key,
"UninstallString",
&uninstall_command_line(uninstall_exe),
)?;
let size_kb = (directory_size_bytes(install_dir)? / 1024).min(u32::MAX as u64) as u32;
set_reg_dword(key, "EstimatedSize", size_kb)?;
RegCloseKey(key).ok()?;
}
Ok(())
}
unsafe fn read_reg_string(key: HKEY, name: &str) -> Result<String> {
let name_w = wide(name);
let mut kind = REG_VALUE_TYPE::default();
let mut len = 0u32;
RegQueryValueExW(key, PCWSTR(name_w.as_ptr()), None, Some(&mut kind), None, Some(&mut len))
.ok()?;
if len < 2 {
return Ok(String::new());
}
let mut buf = vec![0u8; len as usize];
RegQueryValueExW(
key,
PCWSTR(name_w.as_ptr()),
None,
Some(&mut kind),
Some(buf.as_mut_ptr()),
Some(&mut len),
)
.ok()?;
let wide_len = (len as usize / 2).saturating_sub(1);
let chars: Vec<u16> = buf
.chunks_exact(2)
.take(wide_len)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
Ok(String::from_utf16_lossy(&chars))
}
unsafe fn set_reg_dword(key: HKEY, name: &str, value: u32) -> Result<()> {
let name_w = wide(name);
let bytes = value.to_le_bytes();
RegSetValueExW(key, PCWSTR(name_w.as_ptr()), 0, REG_DWORD, Some(&bytes)).ok()?;
Ok(())
}
unsafe fn set_reg_string(key: HKEY, name: &str, value: &str) -> Result<()> {
let name_w = wide(name);
let value_w = wide(value);
let bytes_len = value_w.len() * 2;
let bytes = std::slice::from_raw_parts(value_w.as_ptr() as *const u8, bytes_len);
RegSetValueExW(key, PCWSTR(name_w.as_ptr()), 0, REG_SZ, Some(bytes)).ok()?;
Ok(())
}
pub fn delete_uninstall_registry(all_users: bool, registry_key: &str) -> Result<()> {
let root = if all_users {
HKEY_LOCAL_MACHINE
} else {
HKEY_CURRENT_USER
};
let sub = wide(&format!(
r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{registry_key}"
));
unsafe {
let _ = RegDeleteTreeW(root, PCWSTR(sub.as_ptr()));
}
Ok(())
}
pub fn is_elevated() -> bool {
unsafe {
let mut token = HANDLE::default();
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() {
return false;
}
let mut elevation = TOKEN_ELEVATION::default();
let mut returned = 0u32;
let ok = GetTokenInformation(
token,
TokenElevation,
Some(&mut elevation as *mut _ as *mut _),
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
&mut returned,
)
.is_ok();
let _ = CloseHandle(token);
ok && elevation.TokenIsElevated != 0
}
}
pub fn elevate_helper(helper_path: &Path, args: &str) -> Result<()> {
let file = wide(&helper_path.to_string_lossy());
let params = wide(args);
let op = wide("runas");
unsafe {
let ret = ShellExecuteW(
None,
PCWSTR(op.as_ptr()),
PCWSTR(file.as_ptr()),
PCWSTR(params.as_ptr()),
None,
SW_SHOWNORMAL,
);
if (ret.0 as usize) <= 32 {
bail!("ShellExecute elevation failed ({})", ret.0 as usize);
}
}
Ok(())
}
pub fn default_install_dir(all_users: bool, edition: FromChatEdition) -> Result<PathBuf> {
let folder = edition.install_folder_name();
if all_users {
let program_files =
std::env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".into());
Ok(PathBuf::from(program_files).join(folder))
} else {
let local = std::env::var("LOCALAPPDATA").context("LOCALAPPDATA")?;
Ok(PathBuf::from(local).join("Programs").join(folder))
}
}
pub fn programs_folder(all_users: bool) -> Result<PathBuf> {
if all_users {
known_folder(FOLDERID_CommonPrograms)
} else {
known_folder(FOLDERID_Programs)
}
}
pub fn desktop_folder() -> Result<PathBuf> {
known_folder(FOLDERID_Desktop)
}
pub struct NamedPipeServer {
handle: HANDLE,
}
#[cfg(windows)]
// HANDLE is thread-safe to use from the owning pipe server after `accept`.
unsafe impl Send for NamedPipeServer {}
impl NamedPipeServer {
pub fn create(name: &str) -> Result<Self> {
let w = wide(name);
unsafe {
let handle = CreateNamedPipeW(
PCWSTR(w.as_ptr()),
windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES(0x00000003),
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
1,
64 * 1024,
64 * 1024,
0,
None,
);
if handle == INVALID_HANDLE_VALUE {
bail!("CreateNamedPipe failed");
}
Ok(Self { handle })
}
}
pub fn accept(&self) -> Result<()> {
unsafe {
ConnectNamedPipe(self.handle, None)
.ok()
.context("ConnectNamedPipe")?;
}
Ok(())
}
pub fn handle(&self) -> HANDLE {
self.handle
}
}
impl Drop for NamedPipeServer {
fn drop(&mut self) {
unsafe {
let _ = DisconnectNamedPipe(self.handle);
let _ = CloseHandle(self.handle);
}
}
}
pub fn pipe_read_line(handle: HANDLE) -> Result<String> {
let mut buf = Vec::new();
let mut byte = [0u8; 1];
loop {
let mut read = 0u32;
unsafe {
ReadFile(handle, Some(&mut byte), Some(&mut read), None)
.ok()
.context("ReadFile")?;
}
if read == 0 {
break;
}
if byte[0] == b'\n' {
break;
}
if byte[0] != b'\r' {
buf.push(byte[0]);
}
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
pub fn pipe_write_line(handle: HANDLE, line: &str) -> Result<()> {
let mut data = line.as_bytes().to_vec();
data.push(b'\n');
let mut written = 0u32;
unsafe {
WriteFile(handle, Some(&data), Some(&mut written), None)
.ok()
.context("WriteFile")?;
FlushFileBuffers(handle)
.ok()
.context("FlushFileBuffers")?;
}
Ok(())
}
pub fn open_pipe_client(name: &str) -> Result<HANDLE> {
let w = wide(name);
unsafe {
let handle = CreateFileW(
PCWSTR(w.as_ptr()),
GENERIC_READ.0 | GENERIC_WRITE.0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
None,
)?;
Ok(handle)
}
}
/// Named pipe client/server handle (re-exported for helper binary).
pub type PipeHandle = HANDLE;
@@ -0,0 +1,22 @@
[package]
name = "fromchat-installer-helper"
version.workspace = true
edition.workspace = true
[[bin]]
name = "FromChat-Installer-Helper"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
clap.workspace = true
fromchat-installer-common.workspace = true
serde_json.workspace = true
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = ["Win32_System_Com"] }
[build-dependencies]
winres = "0.1"
image = { version = "0.25", default-features = false, features = ["png"] }
ico = "0.4"

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