GLEGram 12.5 — Initial public release

Based on Swiftgram 12.5 (Telegram iOS 12.5).
All GLEGram features ported and organized in GLEGram/ folder.

Features: Ghost Mode, Saved Deleted Messages, Content Protection Bypass,
Font Replacement, Fake Profile, Chat Export, Plugin System, and more.

See CHANGELOG_12.5.md for full details.
This commit is contained in:
Leeksov
2026-04-06 09:48:12 +03:00
commit 4647310322
39685 changed files with 11052678 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
build --action_env=ZERO_AR_DATE=1
build --apple_platform_type=ios
build --enable_platform_specific_config
build --apple_crosstool_top=@local_config_apple_cc//:toolchain
build --crosstool_top=@local_config_apple_cc//:toolchain
build --host_crosstool_top=@local_config_apple_cc//:toolchain
build --per_file_copt=".*\.m$","@-fno-objc-msgsend-selector-stubs"
build --per_file_copt=".*\.mm$","@-fno-objc-msgsend-selector-stubs"
build --features=debug_prefix_map_pwd_is_dot
build --features=swift.cacheable_swiftmodules
build --features=swift.debug_prefix_map
build --features=swift.enable_vfsoverlays
build:dbg --features=swift.emit_swiftsourceinfo
build --strategy=Genrule=standalone
build --spawn_strategy=standalone
build --strategy=SwiftCompile=worker
#common --registry=https://raw.githubusercontent.com/bazelbuild/bazel-central-registry/main/
# SourceKit BSP: Swift indexing features
common --features=swift.index_while_building
common --features=swift.use_global_index_store
common --features=swift.use_global_module_cache
common --features=oso_prefix_is_pwd
# SourceKit BSP: Index build config (used for background indexing)
common:index_build --experimental_convenience_symlinks=ignore
common:index_build --show_result=0
common:index_build --noshow_loading_progress
common:index_build --noshow_progress
common:index_build --noannounce_rc
common:index_build --noshow_timestamps
common:index_build --curses=no
common:index_build --color=no
+2
View File
@@ -0,0 +1,2 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
spm-files
+15
View File
@@ -0,0 +1,15 @@
# Linguist overrides for vendored code
submodules/AsyncDisplayKit/* linguist-vendored
submodules/ffmpeg/* linguist-vendored
submodules/HockeySDK-iOS/* linguist-vendored
submodules/libphonenumber/* linguist-vendored
submodules/libtgvoip/* linguist-vendored
submodules/lottie-ios/* linguist-vendored
submodules/Opus/* linguist-vendored
submodules/OpusBinding/* linguist-vendored
submodules/rlottie/rlottie/* linguist-vendored
submodules/sqlcipher/* linguist-vendored
submodules/Stripe/* linguist-vendored
submodules/ton/tonlib-src/* linguist-vendored
submodules/webp/include/* linguist-vendored
third-party/* linguist-vendored
+118
View File
@@ -0,0 +1,118 @@
# Contributing
This document describes how you can contribute to Telegram-iOS. Please read it carefully.
**Table of Contents**
* [What contributions are accepted](#what-contributions-are-accepted)
* [Build instructions](#build-instructions)
* [Pull upstream changes into your fork regularly](#pull-upstream-changes-into-your-fork-regularly)
* [How to get your pull request accepted](#how-to-get-your-pull-request-accepted)
* [Keep your pull requests limited to a single issue](#keep-your-pull-requests-limited-to-a-single-issue)
* [Squash your commits to a single commit](#squash-your-commits-to-a-single-commit)
* [Don't mix code changes with whitespace cleanup](#dont-mix-code-changes-with-whitespace-cleanup)
* [Keep your code simple!](#keep-your-code-simple)
* [Test your changes!](#test-your-changes)
* [Write a good commit message](#write-a-good-commit-message)
## What contributions are accepted
We highly appreciate your contributions in the matter of fixing bugs and optimizing the Telegram-iOS source code and its documentation. In case of fixing the existing user experience please push to your fork and [submit a pull request][pr].
Wait for us. We try to review your pull requests as fast as possible.
If we find issues with your pull request, we may suggest some changes and improvements.
Unfortunately we **do not merge** any pull requests that have new feature implementations, translations to new languages and those which introduce any new user interface elements.
If you have a translations-related contribution, check out [Translations platform][translate].
Telegram-iOS is not a standalone application but a part of [Telegram project][telegram], so all the decisions about the features, languages, user experience, user interface and the design are made inside Telegram team, often according to some roadmap which is not public.
## Build instructions
See the [README.md][build_instructions] for details on the various build
environments.
## Pull upstream changes into your fork regularly
Telegram-iOS is advancing quickly. It is therefore critical that you pull upstream changes into your fork on a regular basis. Nothing is worse than putting in a days of hard work into a pull request only to have it rejected because it has diverged too far from upstream.
To pull in upstream changes:
git remote add upstream https://github.com/TelegramMessenger/Telegram-iOS.git
git fetch upstream master
Check the log to be sure that you actually want the changes, before merging:
git log upstream/master
Then rebase your changes on the latest commits in the `master` branch:
git rebase upstream/master
After that, you have to force push your commits:
git push --force
For more info, see [GitHub Help][help_fork_repo].
## How to get your pull request accepted
We want to improve Telegram-iOS with your contributions. But we also want to provide a stable experience for our users and the community. Follow these rules and you should succeed without a problem!
### Keep your pull requests limited to a single issue
Pull requests should be as small/atomic as possible. Large, wide-sweeping changes in a pull request will be **rejected**, with comments to isolate the specific code in your pull request. Some examples:
* If you are making spelling corrections in the docs, don't modify other files.
* If you are adding new functions don't '*cleanup*' unrelated functions. That cleanup belongs in another pull request.
#### Squash your commits to a single commit
To keep the history of the project clean, you should make one commit per pull request.
If you already have multiple commits, you can add the commits together (squash them) with the following commands in Git Bash:
1. Open `Git Bash` (or `Git Shell`)
2. Enter following command to squash the recent {N} commits: `git reset --soft HEAD~{N} && git commit` (replace `{N}` with the number of commits you want to squash)
3. Press <kbd>i</kbd> to get into Insert-mode
4. Enter the commit message of the new commit
5. After adding the message, press <kbd>ESC</kbd> to get out of the Insert-mode
6. Write `:wq` and press <kbd>Enter</kbd> to save the new message or write `:q!` to discard your changes
7. Enter `git push --force` to push the new commit to the remote repository
For example, if you want to squash the last 5 commits, use `git reset --soft HEAD~5 && git commit`
### Don't mix code changes with whitespace cleanup
If you change two lines of code and correct 200 lines of whitespace issues in a file the diff on that pull request is functionally unreadable and will be **rejected**. Whitespace cleanups need to be in their own pull request.
### Keep your code simple!
Please keep your code as clean and straightforward as possible.
Furthermore, the pixel shortage is over. We want to see:
* `opacity` instead of `o`
* `placeholder` instead of `ph`
* `myFunctionThatDoesThings()` instead of `mftdt()`
### Test your changes!
Before you submit a pull request, please test your changes. Verify that Telegram-iOS still works and your changes don't cause other issue or crashes.
### Write a good commit message
* Explain why you make the changes. [More infos about a good commit message.][commit_message]
* If you fix an issue with your commit, please close the issue by [adding one of the keywords and the issue number][closing-issues-via-commit-messages] to your commit message.
For example: `Fix #545`
[//]: # (LINKS)
[telegram]: https://telegram.org/
[help_fork_repo]: https://help.github.com/articles/fork-a-repo/
[help_change_commit_message]: https://help.github.com/articles/changing-a-commit-message/
[commit_message]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
[pr]: https://github.com/TelegramMessenger/Telegram-iOS/compare
[build_instructions]: https://github.com/TelegramMessenger/Telegram-iOS#quick-compilation-guide
[closing-issues-via-commit-messages]: https://help.github.com/articles/closing-issues-via-commit-messages/
[translate]: https://translations.telegram.org
+40
View File
@@ -0,0 +1,40 @@
---
name: "\U0001F41E Bug Report"
about: "Report a bug if something isn't working as expected in Telegram Messenger for iOS."
title: ""
labels: bug
assignees: ""
---
<!-- Thanks for reporting issues of Telegram Messenger for iOS!
This is a bug report template. Please, be as descriptive as possible. Issues lacking detail, or for any other reason than to report a bug, may be closed without action.
First, complete the checklist by replacing the empty checkboxes [] with checked ones [x]. -->
### Checklist
- [ ] I am reporting an issue in existing functionality that does not work as intended
- [ ] I've searched for existing [GitHub issues](https://github.com/telegrammessenger/Telegram-iOS/issues)
### Description
Describe the issue that you are experiencing
### Expected Behavior
Tell us what should happen
### Actual Behavior
Tell us what happens instead
### Steps to Reproduce
1.
2.
3.
### Screenshots and Videos
Remove, if not applicable
### Environment
**Device:** `iPhone/iPad X`
**iOS version**: `13.X`
**App version:** `7.X`
+109
View File
@@ -0,0 +1,109 @@
name: CI
on:
# push:
# branches: [ master ]
workflow_dispatch:
jobs:
build:
runs-on: macos-13
steps:
- uses: actions/checkout@v2
with:
submodules: 'recursive'
fetch-depth: '0'
- name: Set active Xcode path
run: |
XCODE_VERSION=$(cat versions.json | python3 -c 'import json,sys;obj=json.load(sys.stdin);print(obj["xcode"]);')
sudo xcode-select -s /Applications/Xcode_$XCODE_VERSION.app/Contents/Developer
- name: Create canonical source directory
run: |
set -x
sudo mkdir -p /Users/Shared
cp -R $GITHUB_WORKSPACE /Users/Shared/
mv /Users/Shared/$(basename $GITHUB_WORKSPACE) /Users/Shared/telegram-ios
- name: Build the App
run: |
set -x
# source code paths are included in the final binary, so we need to make them stable across builds
SOURCE_DIR=/Users/Shared/telegram-ios
# use canonical bazel root
BAZEL_USER_ROOT="/private/var/tmp/_bazel_containerhost"
cd $SOURCE_DIR
BUILD_NUMBER_OFFSET="$(cat build_number_offset)"
export APP_VERSION=$(cat versions.json | python3 -c 'import json,sys;obj=json.load(sys.stdin);print(obj["app"]);')
export COMMIT_COUNT=$(git rev-list --count HEAD)
export COMMIT_COUNT="$(($COMMIT_COUNT+$BUILD_NUMBER_OFFSET))"
export BUILD_NUMBER="$COMMIT_COUNT"
echo "BUILD_NUMBER=$(echo $BUILD_NUMBER)" >> $GITHUB_ENV
echo "APP_VERSION=$(echo $APP_VERSION)" >> $GITHUB_ENV
python3 build-system/Make/ImportCertificates.py --path build-system/fake-codesigning/certs
python3 -u build-system/Make/Make.py \
--bazelUserRoot="$BAZEL_USER_ROOT" \
build \
--configurationPath="build-system/appstore-configuration.json" \
--codesigningInformationPath=build-system/fake-codesigning \
--configuration=release_arm64 \
--buildNumber="$BUILD_NUMBER"
# collect ipa
OUTPUT_PATH="build/artifacts"
rm -rf "$OUTPUT_PATH"
mkdir -p "$OUTPUT_PATH"
for f in bazel-out/applebin_ios-ios_arm*-opt-ST-*/bin/Telegram/Telegram.ipa; do
cp "$f" $OUTPUT_PATH/
done
# collect dsym
mkdir -p build/DSYMs
for f in bazel-out/applebin_ios-ios_arm*-opt-ST-*/bin/Telegram/*.dSYM; do
cp -R "$f" build/DSYMs/
done
zip -r "./$OUTPUT_PATH/Telegram.DSYMs.zip" build/DSYMs 1>/dev/null
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: build-${{ env.BUILD_NUMBER }}
release_name: Telegram ${{ env.APP_VERSION }} (${{ env.BUILD_NUMBER }})
body: |
An unsigned build of Telegram for iOS ${{ env.APP_VERSION }} (${{ env.BUILD_NUMBER }})
draft: false
prerelease: false
- name: Upload Release IPA
id: upload-release-ipa
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: /Users/Shared/telegram-ios/build/artifacts/Telegram.ipa
asset_name: Telegram.ipa
asset_content_type: application/zip
- name: Upload Release DSYM
id: upload-release-dsym
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: /Users/Shared/telegram-ios/build/artifacts/Telegram.DSYMs.zip
asset_name: Telegram.DSYMs.zip
asset_content_type: application/zip
+32
View File
@@ -0,0 +1,32 @@
# Build
bazel-*
build/
.build/
*.ipa
*.xcarchive
# Secrets (never commit)
build-system/real-codesigning/certs/*.p12
build-system/real-codesigning/certs/*.cer
build-system/real-codesigning/profiles/*.mobileprovision
build-input/configuration-repository/
build-input/configuration-repository-workdir/
# IDE
.DS_Store
*.xcworkspace
*.xcodeproj
xcuserdata/
DerivedData/
# Logs
*.log
hs_err_*.log
# Bazel
/private/
# Large binaries
build-input/bazel-*
scripts/Telegram
third-party/opus/prebuilt/*/libopus.a
+203
View File
@@ -0,0 +1,203 @@
stages:
- build
- deploy
- verifysanity
- verify
- submit
variables:
LANG: "en_US.UTF-8"
LC_ALL: "en_US.UTF-8"
GIT_SUBMODULE_STRATEGY: normal
internal:
tags:
- ios_internal
stage: build
only:
- master
except:
- tags
script:
- export PATH=/opt/homebrew/opt/ruby/bin:$PATH
- export PATH=`gem environment gemdir`/bin:$PATH
- python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="build-system/appcenter-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=adhoc --configuration=release_arm64
- python3 -u build-system/Make/DeployToFirebase.py --configuration="$TELEGRAM_PRIVATE_DATA_PATH/firebase-configurations/firebase-internal.json" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip"
- python3 -u build-system/Make/DeployBuild.py --configuration="$TELEGRAM_PRIVATE_DATA_PATH/deploy-configurations/internal-configuration.json" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip"
- rm -rf build-input/configuration-repository-workdir
- rm -rf build-input/configuration-repository
- python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="$TELEGRAM_PRIVATE_DATA_PATH/build-configurations/enterprise-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=enterprise --configuration=release_arm64
- python3 -u build-system/Make/DeployToFirebase.py --configuration="$TELEGRAM_PRIVATE_DATA_PATH/firebase-configurations/firebase-enterprise.json" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip"
- python3 -u build-system/Make/DeployBuild.py --configuration="$TELEGRAM_PRIVATE_DATA_PATH/deploy-configurations/enterprise-configuration.json" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip"
environment:
name: internal
artifacts:
when: always
paths:
- build/artifacts
expire_in: 1 week
internal_testflight:
tags:
- ios_internal
stage: deploy
only:
- master
except:
- tags
script:
- python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="build-system/appstore-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=appstore --configuration=release_arm64
- python3 -u build-system/Make/Make.py remote-deploy-testflight --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip"
environment:
name: testflight_llc
artifacts:
when: always
paths:
- build/artifacts
expire_in: 1 week
appstore_development:
tags:
- ios_internal
stage: build
only:
- appstore-development
except:
- tags
script:
- python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="$TELEGRAM_PRIVATE_DATA_PATH/build-configurations/enterprise-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=enterprise --configuration=release_arm64
environment:
name: appstore-development
artifacts:
paths:
- build/artifacts/Telegram.DSYMs.zip
expire_in: 1 week
experimental_i:
tags:
- ios_experimental
stage: build
only:
- experimental-3
except:
- tags
script:
- export PATH=/opt/homebrew/opt/ruby/bin:$PATH
- export PATH=`gem environment gemdir`/bin:$PATH
- python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="build-system/appcenter-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=adhoc --configuration=release_arm64
- python3 -u build-system/Make/Make.py remote-deploy-testflight --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip"
environment:
name: experimental
artifacts:
paths:
- build/artifacts/Telegram.DSYMs.zip
expire_in: 1 week
experimental:
tags:
- ios_internal
stage: build
only:
- experimental-2
except:
- tags
script:
- python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="$TELEGRAM_PRIVATE_DATA_PATH/build-configurations/enterprise-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=enterprise --configuration=release_arm64
environment:
name: experimental-2
artifacts:
paths:
- build/artifacts/Telegram.DSYMs.zip
expire_in: 1 week
beta_testflight:
tags:
- ios_beta
stage: build
only:
- beta
- hotfix
except:
- tags
script:
- export PATH=/opt/homebrew/opt/ruby/bin:$PATH
- export PATH=`gem environment gemdir`/bin:$PATH
- python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="build-system/appstore-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=appstore --configuration=release_arm64
environment:
name: testflight_llc
artifacts:
paths:
- build/artifacts
expire_in: 3 weeks
deploy_beta_testflight:
tags:
- ios_beta
stage: deploy
only:
- beta
- hotfix
except:
- tags
script:
- python3 -u build-system/Make/Make.py remote-deploy-testflight --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip"
environment:
name: testflight_llc
verifysanity_beta_testflight:
tags:
- ios_beta
stage: verifysanity
only:
- beta
- hotfix
except:
- tags
script:
- rm -rf build/verify-input && mkdir -p build/verify-input && mv build/artifacts/Telegram.ipa build/verify-input/TelegramVerifySource.ipa
- python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="build-system/appstore-configuration.json" --codesigningInformationPath=build-system/fake-codesigning --configuration=release_arm64
- python3 -u build-system/Make/Make.py remote-ipa-diff --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --ipa1="build/artifacts/Telegram.ipa" --ipa2="build/verify-input/TelegramVerifySource.ipa"
- if [ $? -ne 0 ]; then echo "Verification failed"; mkdir -p build/verifysanity_artifacts; cp build/artifacts/Telegram.ipa build/verifysanity_artifacts/; exit 1; fi
environment:
name: testflight_llc
artifacts:
when: on_failure
paths:
- build/artifacts
expire_in: 1 week
verify_beta_testflight:
tags:
- ios_beta
stage: verify
only:
- beta
- hotfix
except:
- tags
script:
- rm -rf build/verify-input && mkdir -p build/verify-input && mv build/artifacts/Telegram.ipa build/verify-input/TelegramVerifySource.ipa
- python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --configurationPath="build-system/appstore-configuration.json" --codesigningInformationPath=build-system/fake-codesigning --configuration=release_arm64
- python3 -u build-system/Make/Make.py remote-ipa-diff --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --ipa1="build/artifacts/Telegram.ipa" --ipa2="build/verify-input/TelegramVerifySource.ipa"
- if [ $? -ne 0 ]; then echo "Verification failed"; mkdir -p build/verify_artifacts; cp build/artifacts/Telegram.ipa build/verify_artifacts/; exit 1; fi
environment:
name: testflight_llc
artifacts:
when: on_failure
paths:
- build/artifacts
expire_in: 1 week
submit_appstore:
tags:
- deploy
only:
- beta
- hotfix
stage: submit
needs: []
when: manual
script:
- sh "$TELEGRAM_SUBMIT_BUILD"
environment:
name: testflight_llc
+39
View File
@@ -0,0 +1,39 @@
[submodule "submodules/rlottie/rlottie"]
path = submodules/rlottie/rlottie
url=https://github.com/TelegramMessenger/rlottie.git
[submodule "build-system/bazel-rules/rules_apple"]
path = build-system/bazel-rules/rules_apple
url=https://github.com/ali-fareed/rules_apple.git
[submodule "build-system/bazel-rules/rules_swift"]
path = build-system/bazel-rules/rules_swift
url=https://github.com/bazelbuild/rules_swift.git
[submodule "build-system/bazel-rules/apple_support"]
path = build-system/bazel-rules/apple_support
url = https://github.com/bazelbuild/apple_support.git
[submodule "submodules/TgVoipWebrtc/tgcalls"]
path = submodules/TgVoipWebrtc/tgcalls
url=https://github.com/TelegramMessenger/tgcalls.git
[submodule "third-party/libvpx/libvpx"]
path = third-party/libvpx/libvpx
url = https://github.com/webmproject/libvpx.git
[submodule "third-party/webrtc/webrtc"]
path = third-party/webrtc/webrtc
url = https://github.com/ali-fareed/webrtc.git
[submodule "build-system/bazel-rules/rules_xcodeproj"]
path = build-system/bazel-rules/rules_xcodeproj
url = https://github.com/MobileNativeFoundation/rules_xcodeproj.git
[submodule "submodules/LottieCpp/lottiecpp"]
path = submodules/LottieCpp/lottiecpp
url = https://github.com/ali-fareed/lottiecpp.git
[submodule "third-party/dav1d/dav1d"]
path = third-party/dav1d/dav1d
url = https://github.com/ali-fareed/dav1d.git
[submodule "third-party/td/td"]
path = third-party/td/td
url = https://github.com/tdlib/td
[submodule "third-party/XcodeGen"]
path = third-party/XcodeGen
url = https://github.com/yonaskolb/XcodeGen.git
[submodule "build-system/bazel-rules/sourcekit-bazel-bsp"]
path = build-system/bazel-rules/sourcekit-bazel-bsp
url = https://github.com/spotify/sourcekit-bazel-bsp.git
+19
View File
@@ -0,0 +1,19 @@
{
"configurations": [
{
"name": "Debug Telegram",
"type": "lldb-dap",
"request": "attach",
"preLaunchTask": "_launch_telegram",
"debuggerRoot": "${workspaceFolder}",
"attachCommands": [
"command script import '${workspaceFolder}/scripts/lldb_attach.py'"
],
"terminateCommands": [
"command script import '${workspaceFolder}/scripts/lldb_kill_app.py'"
],
"internalConsoleOptions": "openOnSessionStart",
"timeout": 9999
}
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"swift.disableSwiftPackageManagerIntegration": true,
"swift.autoGenerateLaunchConfigurations": false,
"swift.disableAutoResolve": true,
"search.followSymlinks": false,
"files.exclude": {
".git/**": true
},
"files.watcherExclude": {
".git/**": true
},
"search.exclude": {
".git/**": true
},
"swift.sourcekit-lsp.backgroundIndexing": "on",
"swift.sourcekit-lsp.trace.server": "messages",
"terminal.integrated.profiles.osx": {
"zsh": {
"path": "/bin/zsh",
"args": [
"-l",
"-i"
]
}
},
"swift.sourcekit-lsp.serverPath": "${workspaceFolder}/build-input/sourcekit-lsp",
/* MARK: Swiftgram */
"editor.wordWrap": "on"
}
+110
View File
@@ -0,0 +1,110 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Select Simulator for Apple Development",
"type": "shell",
"command": "./scripts/select_simulator.sh",
"presentation": {
"reveal": "always",
"focus": true,
"panel": "dedicated"
},
"problemMatcher": []
},
{
"label": "Build Telegram",
"type": "shell",
"command": "./scripts/lldb_build.sh",
"options": {
"env": {
"BAZEL_LABEL_TO_RUN": "//Telegram:Swiftgram",
"BAZEL_EXTRA_BUILD_FLAGS": ""
}
},
"group": {
"kind": "build"
},
"problemMatcher": [
{
"owner": "bazel",
"source": "bazel",
"fileLocation": [
"relative",
"${workspaceFolder}"
],
"pattern": {
"regexp": "^(.+?):(\\d+):(\\d+):\\s+(error|warning|note):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"message": 5
}
}
]
},
{
"label": "_launch_telegram",
"type": "shell",
"command": "./scripts/lldb_launch_and_debug.sh",
"options": {
"env": {
"BAZEL_LABEL_TO_RUN": "//Telegram:Swiftgram",
"BAZEL_EXTRA_BUILD_FLAGS": "",
"BAZEL_LAUNCH_ARGS": ""
}
},
"presentation": {
"reveal": "always"
},
"hide": true,
"isBackground": true,
"problemMatcher": [
{
"owner": "bazel",
"source": "bazel",
"fileLocation": [
"relative",
"${workspaceFolder}"
],
"pattern": {
"regexp": "launcher_error in (.*): (.*)",
"kind": "file",
"file": 1,
"message": 2
},
"background": {
"activeOnStart": true,
"beginsPattern": "^Starting launch task\\.\\.\\.$",
"endsPattern": "^app.swiftgram.ios: .*"
}
},
{
"owner": "bazel",
"source": "bazel",
"fileLocation": [
"relative",
"${workspaceFolder}"
],
"pattern": {
"regexp": "^(.+?):(\\d+):(\\d+):\\s+(error|warning|note):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"message": 5
},
"background": {
"activeOnStart": true,
"beginsPattern": "^Starting launch task\\.\\.\\.$",
"endsPattern": "^app.swiftgram.ios: .*"
}
}
],
"runOptions": {
"instanceLimit": 1
}
}
]
}
Symlink
+1
View File
@@ -0,0 +1 @@
swiftgram-scripts/AGENTS.md
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+34
View File
@@ -0,0 +1,34 @@
load("@sourcekit_bazel_bsp//rules:setup_sourcekit_bsp.bzl", "setup_sourcekit_bsp")
setup_sourcekit_bsp(
name = "setup_sourcekit_bsp",
bazel_wrapper = "./build-input/bazel-8.4.2-darwin-arm64",
files_to_watch = [
"Telegram/**/*.swift",
"Telegram/**/*.h",
"Telegram/**/*.m",
"Telegram/**/*.mm",
"Telegram/**/*.c",
"Telegram/**/*.cpp",
"submodules/**/*.swift",
"submodules/**/*.h",
"submodules/**/*.m",
"submodules/**/*.mm",
"submodules/**/*.c",
"submodules/**/*.cpp",
],
aquery_flags = [
"define=telegramVersion=12.5",
"define=buildNumber=100000",
],
index_flags = [
"config=index_build",
"define=telegramVersion=12.5",
"define=buildNumber=100000",
],
index_build_batch_size = 10,
targets = [
"//Telegram:Telegram",
],
merge_lsp_config = False,
)
+214
View File
@@ -0,0 +1,214 @@
# GLEGram 12.5 — Changelog
**Base:** Swiftgram 12.5 (Telegram iOS 12.5)
**Build:** 100005
**Date:** 2026-04-05
---
## Migration from 12.3 to 12.5
Full port of all GLEGram features onto the Swiftgram 12.5 codebase.
166 files changed, 8211 insertions, 77 new files.
All GLEGram code organized in `GLEGram/` folder and marked with `// MARK: - GLEGram` in Telegram source files.
---
## New Features
### Double Bottom
- Secret passcode unlocks a single hidden account
- Keychain-based passcode storage
- Settings controller with enable/disable toggle
- Module: `GLEGram/DoubleBottom/`
### Chat Password Protection
- Lock individual chats/folders with device passcode or custom password
- Keychain-based per-peer password storage
- Settings controller with peer selection
- Module: `GLEGram/ChatPassword/`
### Voice Morpher
- 6 voice presets: Disabled, Anonymous, Female, Male, Child, Robot
- OGG processing engine (ready for OpusBinding integration)
- UserDefaults persistence with change notifications
- Module: `GLEGram/VoiceMorpher/`
### SGLocalPremium
- Full local Premium emulation without subscription
- Unlimited pinned chats, folders, chats per folder
- Saved Message Tags support
- Server sync disabling for pinned/folders/folder order
- Module: `GLEGram/SGLocalPremium/`
### Plugin System (Extended)
- Inline JS plugin code editor
- Plugin metadata parsing and file management
- GLEGramFeatures global feature flags
- Module: `GLEGram/GLESettingsUI/`, `Swiftgram/SGSimpleSettings/`
### Video Wallpapers
- Video file picker (Files app + Gallery)
- Looping video playback as chat background
- Power saving mode support
- Intensity/dimming controls
- Preview controller with Cancel/Done
- Module: integrated in `WallpaperBackgroundNode`, `ThemeGridController`
---
## Ported Features (from 12.3)
### Ghost Mode (20+ toggles)
- Message send delay (0/12/30/45 sec)
- Hide online status with periodic offline timer
- Hide typing, recording, uploading, choosing, playing, speaking statuses
- Disable read receipts (messages + stories) with peer whitelist
- Files: `ManagedAccountPresence.swift`, `ManagedLocalInputActivities.swift`, `PendingMessageManager.swift`
### Saved Deleted Messages (AyuGram-style)
- Auto-save deleted messages to SavedDeleted namespace (1338)
- Save media, reactions, bot messages
- Edit history tracking with inline display
- Search in saved deleted messages
- Storage management and clear action
- Files: `AccountStateManagementUtils.swift`, `DeleteMessages.swift`, `SearchMessages.swift`
### Fake Profile
- Custom first/last name, username, phone, ID
- Premium/verified/scam/fake/support/bot badges
- Per-user targeting
- Module: `Swiftgram/SGSettingsUI/`
### Font Replacement (A-Font style)
- Custom font from system or imported file
- Separate regular and bold font selection
- Size multiplier (50-150%)
- Font cache with clearCache() support
- Files: `Display/Source/Font.swift`
### Profile Cover
- Custom image/video cover on profile
- AVPlayer looping playback
- Module: `Swiftgram/SGSettingsUI/`
### Chat Export
- Export as JSON, TXT, HTML
- AyuGram-style HTML export with CSS/JS
- Context menu in profile "More" button
- Module: `GLEGram/SGChatExport/`
### Fake Location
- CLLocationManager swizzling
- Map picker controller
- Persistent coordinates
- Module: `GLEGram/SGFakeLocation/`
### Supporters/Badges System
- Encrypted API with AES-256 + HMAC-SHA256
- SSL certificate pinning
- Badge image cache
- Server badges with custom colors/images
- Subscription/trial tracking with expiry date display
- Module: `GLEGram/SGSupporters/`
### Demo Login (App Store Review)
- Backend-driven phone number interception
- Auto code polling and entry
- Auto 2FA password entry
- Files: `AuthorizationSequenceController.swift`, `GLEDemoLoginService.swift`
### Protected Content Override
- Save protected/copy-protected media
- Save self-destructing messages
- Disable screenshot detection
- Disable secret chat blur on screenshot
- Files: `SyncCore_AutoremoveTimeoutMessageAttribute.swift`, `ChatMessageInteractiveMediaNode.swift`
### Online Status Recording
- Track peer online/offline timestamps
- Emulate Premium "Last seen" for hidden users
- Module: `Swiftgram/SGSettingsUI/`
### Other Ported Features
- Hide proxy sponsor
- Disable all ads
- Local Premium toggle
- Scroll to top button
- Telescope (video circles/voice from gallery)
- Unlimited favorite stickers
- Compact numbers disable
- Zalgo text removal
- Gift ID display in gift info
- Local stars balance (feelRich)
- Per-account notification mute
- Gated features with deeplink unlock
- Plugin system with PythonKit bridge
---
## UI/Branding Changes
- App name: **GLEGram**
- Default icon: **GLEGramDarkPurple**
- 7 alternate icons: DarkPurple, Black, Green, Pink, Purple, Red, Duck
- App badges: GLEGram-branded (Sky, Night, Pro, Titanium, Day, Sparkling, Ducky)
- Settings icons: GLEGram-branded Swiftgram/SwiftgramPro icons
- Composer icon: GLEGram.icon with GLEGramDarkPurple.png
- Intro sphere: GLEGram-branded telegram_sphere@2x.png
- CFBundleDisplayName: GLEGram (all extensions)
- URL scheme: `glegram://` added
- Notification service: processDeletedMessages
- GLEGram tab in Settings with subscription expiry date label
---
## Build System Changes
- Bazel JDK fix: `--server_javabase` for JDK 21 (fixes SIGBUS on macOS 15.7.4)
- Prebuilt opus: instant build instead of 10min genrule
- GLEGram BUILD target with Swiftgram alias
- 15 build scripts ported (buildprod, buildsim, deploy, sign, etc.)
- Real codesigning profiles
- Provisioning profile fallback logic
- Extension disabling support
---
## TLS ClientHello Improvements (Desktop-like fingerprint)
| # | Change | Detail |
|---|--------|--------|
| 4 | Cipher suites | 15 suites, Desktop order, removed c00a/c009 |
| 5 | Session Ticket | Extension 0x0023 added |
| 6 | ALPS | Extension 0x44cd added (h2) |
| 7 | Supported groups | Removed P-521, kept x25519/P-256/P-384 |
| 8 | Signature algorithms | 8 algos, removed SHA-1 (0x0201) |
| 9 | Record size limit | 0x0002 instead of 0x0001 |
| 10 | GREASE padding | 2 bytes instead of 4 |
---
## Localization
- 110 language files with 386 strings each (107 GLEGram-specific strings added)
- Full Russian and English translations for all GLEGram features
- 5 new 12.5 strings preserved (ChatList.Lines, CompactMessagePreview)
---
## File Structure
```
GLEGram/
├── ChatPassword/ (chat lock)
├── DoubleBottom/ (hidden accounts)
├── GLESettingsUI/ (18 controllers + plugins)
├── SGChatExport/ (HTML/JSON/TXT export)
├── SGDeletedMessages/ (saved deleted messages)
├── SGFakeLocation/ (location spoofing)
├── SGLocalPremium/ (premium emulation)
├── SGSupporters/ (badges, subscriptions)
├── TorEmbedded/ (Tor stub)
└── VoiceMorpher/ (voice effects)
```
+119
View File
@@ -0,0 +1,119 @@
# GLEGram 12.5 — Что нового
---
## Обновление до Telegram 12.5
Полный переход на новую базу Telegram iOS 12.5. Все функции GLEGram сохранены и работают на актуальной версии.
---
## Новые функции
### Двойное дно
Скрытые аккаунты с отдельным паролем. При разблокировке приложения секретным паролем отображается только выбранный аккаунт. Основной пароль открывает все аккаунты как обычно.
### Пароль на чат
Защита отдельных чатов и папок паролем. Можно использовать пароль устройства или задать свой. При открытии защищённого чата потребуется ввести пароль.
### Изменение голоса
6 пресетов голоса для голосовых сообщений: обычный, анонимный, женский, мужской, детский, робот.
### Локальный Premium
Эмуляция Premium-функций без подписки: безлимитные закрепы, папки, теги в Избранном, значок Premium. Серверная синхронизация отключается — ваши настройки не сбрасываются.
### Видеообои
Установка видео в качестве фона чата. Поддержка файлов и галереи, зацикленное воспроизведение, регулировка яркости, режим энергосбережения.
### Система плагинов
Встроенный редактор JS-плагинов. Создание, редактирование и управление плагинами прямо в приложении.
---
## Приватность и безопасность
### Режим призрака
- **Скрытие онлайна** — периодическая отправка статуса «не в сети»
- **Задержка отправки** — сообщения уходят через 12/30/45 секунд после написания
- **Скрытие набора текста** и всех остальных статусов активности (запись видео, отправка фото, выбор стикера и т.д.)
- **Отключение отметок о прочтении** сообщений и историй с белым списком исключений
### Сохранение удалённых сообщений
Автоматическое сохранение сообщений, которые собеседник удалил. Поддержка медиа, реакций, сообщений от ботов. История редактирований отображается прямо в пузыре сообщения.
### Защищённый контент
- Сохранение защищённых от копирования фото и видео
- Сохранение самоуничтожающихся сообщений
- Отключение детекции скриншотов
- Отключение размытия в секретных чатах при скриншоте
### Фейковая геолокация
Подмена местоположения во всех приложениях. Выбор координат на карте, сохранение между перезапусками.
### Скрытие рекламы
Отключение всей рекламы Telegram и прокси-спонсоров.
---
## Оформление
### Замена шрифтов
Установка любого шрифта вместо стандартного. Отдельный выбор обычного и жирного начертания. Регулировка размера от 50% до 150%. Импорт шрифтов из файлов.
### Фейковый профиль
Локальная подмена имени, юзернейма, телефона, ID и значков для любого пользователя. Видно только вам.
### Обложка профиля
Установка своего изображения или видео в качестве обложки профиля.
### ID подарка
Отображение уникального ID при просмотре информации о подарке.
### Локальный баланс звёзд
Отображение произвольного количества звёзд вместо реального баланса.
---
## Другие функции
### Экспорт чата
Экспорт переписки в HTML, JSON или TXT. Кнопка в меню профиля пользователя.
### Телескоп
Создание видеокружков и голосовых сообщений из файлов галереи.
### Прокрутка наверх
Кнопка быстрой прокрутки к началу чата.
### Безлимитные избранные стикеры
Снятие лимита на количество избранных стикеров.
### Отключение компактных чисел
Полное отображение чисел вместо сокращений (1234 вместо 1.2K).
### Удаление Zalgo-текста
Автоматическая очистка текста от символов Zalgo.
### Уведомления по аккаунтам
Отключение push-уведомлений для отдельных аккаунтов при мультиаккаунте.
---
## Иконки и брендинг
- Новая иконка GLEGram по умолчанию (Dark Purple)
- 7 альтернативных иконок: Black, Green, Pink, Purple, Red, Duck
- Обновлённые значки приложения (7 вариантов)
- Обновлённая анимация запуска
---
## Улучшения соединения
Обновлён TLS-отпечаток для соответствия десктопной версии Telegram. Уменьшает вероятность блокировки соединения.
---
## Подписка GLEGram
Дата окончания подписки теперь отображается рядом с пунктом GLEGram в настройках. Кнопка оформления подписки ведёт в чат поддержки @glesign_support.
+92
View File
@@ -0,0 +1,92 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
GLEGram — a fork of Swiftgram (which is a fork of Telegram iOS). Base version: Telegram 12.5, Swiftgram 12.5. All GLEGram-specific code is marked with `// MARK: - GLEGram` and `// MARK: - End GLEGram` in Telegram source files.
## Build Commands
```bash
# Production IPA (release_arm64)
./scripts/buildprod.sh
# With custom build number
./scripts/buildprod.sh --buildNumber 100006
# Clean build
./scripts/buildprod.sh --clean
# Simulator build
./scripts/buildsim.sh
```
**Known issue:** Bazel 8.4.2 with embedded JDK 24 crashes on macOS 15.7.4+. The build system auto-applies `--server_javabase` pointing to system JDK 21 (configured in `build-system/Make/Make.py`).
**Build target:** `//Telegram:GLEGram` (alias `//Telegram:Swiftgram` for backwards compatibility).
**Build configuration:** `build-system/ipa-build-configuration.json` + `build-system/real-codesigning/` for production signing.
## Architecture
### Three-layer structure
1. **Telegram base** (`submodules/`) — Original Telegram iOS code. GLEGram patches are injected with `// MARK: - GLEGram` markers and wrapped in `#if canImport(SGSimpleSettings)` guards.
2. **Swiftgram layer** (`Swiftgram/`) — ~50 modules: settings UI, localization, config, badges, logging, requests, API, etc. Core module: `SGSimpleSettings` (UserDefaults-backed settings with 150+ keys). Settings controller: `SGSettingsUI/Sources/SGSettingsController.swift`.
3. **GLEGram layer** (`GLEGram/`) — 10 modules with GLEGram-exclusive features:
- `SGSupporters` — Encrypted badge/subscription API (AES-256 + HMAC-SHA256 + SSL pinning)
- `SGDeletedMessages` — AyuGram-style saved deleted messages (namespace 1338)
- `SGFakeLocation` — CLLocationManager swizzling
- `SGChatExport` — HTML/JSON/TXT export
- `SGLocalPremium` — Local Premium emulation
- `DoubleBottom` — Hidden accounts with secret passcode
- `ChatPassword` — Per-chat password protection
- `VoiceMorpher` — Voice preset engine
- `GLESettingsUI` — 18 controllers (paywall, plugins, fonts, fake profile, etc.)
- `TorEmbedded` — Tor stub
### Key modified Telegram files
The heaviest GLEGram patches are in:
- `AppDelegate.swift` — App icons, SGConfig.isBetaBuild, supporters init, ghost delay, deeplinks
- `ChatController.swift` — Ghost mode delay, saved deleted hooks
- `AccountStateManagementUtils.swift` — Deleted message saving, edit history
- `PendingMessageManager.swift` — Ghost delay timer (GhostDelayedSendAttribute)
- `ManagedAccountPresence.swift` — Periodic offline timer for ghost mode
- `ManagedLocalInputActivities.swift` — Hide typing/recording/uploading statuses
- `DeleteMessages.swift` — SavedDeleted namespace handling
- `PeerInfoScreen.swift` — GLEGram settings tab, badges, export
- `Font.swift` (Display) — A-Font style font replacement with cache
- `MTTcpConnection.m` — Custom TLS ClientHello fingerprint
### Settings system
- `SGSimpleSettings` (`Swiftgram/SGSimpleSettings/Sources/SimpleSettings.swift`) — All settings keys, defaults, UserDefault properties. GLEGram added 80+ keys (ghost mode, deleted messages, font replacement, fake profile, plugins, gated features, etc.)
- `SGUISettings` (`submodules/TelegramUIPreferences/Sources/Swiftgram/SGUISettings.swift`) — Postbox-backed UI settings
- GLEGram settings controller: `Swiftgram/SGSettingsUI/Sources/GLEGramSettingsController.swift`
### EnqueueMessage.forward
GLEGram added `asCopy: Bool` as 6th parameter to `EnqueueMessage.forward`. All `.forward` constructors need `asCopy:` and all destructuring patterns need 6 wildcards. When adding new `.forward` calls, always include `asCopy: false`.
### BUILD file conventions
GLEGram modules use `//GLEGram/ModuleName:ModuleName` paths. Swiftgram modules use `//Swiftgram/ModuleName:ModuleName`. When adding GLEGram deps to submodule BUILD files, add to the `deps` array (not `sgdeps` which is used for `srcs` in some modules like TelegramUI).
## Code Style
- **Naming**: PascalCase for types, camelCase for variables/methods
- **Imports**: Group and sort; use `#if canImport(SGSimpleSettings)` guards for GLEGram imports in Telegram source files
- **GLEGram markers**: Always wrap GLEGram code in `// MARK: - GLEGram` / `// MARK: - End GLEGram`
- **No tests** are used
## Localization
Strings in `Swiftgram/SGStrings/Strings/` (110 language files, 386 strings). Use `i18n("KEY", lang)` for GLEGram-specific strings. Inline Russian/English with `lang == "ru" ? "..." : "..."` is acceptable for GLEGram-only UI.
## Config
`Swiftgram/SGConfig/Sources/File.swift` — API URLs, supporters API keys, demo login config, `isBetaBuild` flag. Parsed from `BuildConfig.sgConfig` which comes from `variables.bzl` via the build system.
+21
View File
@@ -0,0 +1,21 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "ChatPassword",
module_name = "ChatPassword",
srcs = glob(["Sources/**/*.swift"]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/Display:Display",
"//submodules/TelegramCore:TelegramCore",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/ItemListUI:ItemListUI",
"//submodules/PresentationDataUtils:PresentationDataUtils",
"//submodules/AccountContext:AccountContext",
"//submodules/PasscodeUI:PasscodeUI",
],
visibility = ["//visibility:public"],
)
@@ -0,0 +1,183 @@
// MARK: Swiftgram - Password for selected chats/folders settings
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import PasscodeUI
// MARK: - GLEGram
private enum ProtectedChatsEntry: ItemListNodeEntry {
case enabled(String, Bool)
case useDevicePasscode(String, Bool)
case setCustomPasscode(String)
case addChat(String)
case protectedPeer(id: Int64, title: String)
case notice(String)
var section: ItemListSectionId {
switch self {
case .enabled, .useDevicePasscode, .setCustomPasscode, .notice: return 0
case .addChat, .protectedPeer: return 1
}
}
var stableId: Int {
switch self {
case .enabled: return 0
case .useDevicePasscode: return 1
case .setCustomPasscode: return 2
case .addChat: return 3
case .protectedPeer(let id, _): return 100 + Int(id % 100000)
case .notice: return 200
}
}
static func < (lhs: ProtectedChatsEntry, rhs: ProtectedChatsEntry) -> Bool {
lhs.stableId < rhs.stableId
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! ProtectedChatsArguments
let lang = presentationData.strings.baseLanguageCode
switch self {
case let .enabled(title, value):
return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: section, style: .blocks, updated: { args.toggleEnabled($0) })
case let .useDevicePasscode(title, value):
return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: section, style: .blocks, updated: { args.toggleUseDevicePasscode($0) })
case let .setCustomPasscode(title):
return ItemListDisclosureItem(presentationData: presentationData, title: title, label: "", sectionId: section, style: .blocks, action: { args.setCustomPasscode() })
case let .addChat(title):
return ItemListDisclosureItem(presentationData: presentationData, title: title, label: "", sectionId: section, style: .blocks, action: { args.addChat() })
case let .protectedPeer(_, title):
return ItemListDisclosureItem(presentationData: presentationData, title: title, label: lang == "ru" ? "Удалить" : "Remove", sectionId: section, style: .blocks, action: { [self] in
if case let .protectedPeer(peerId, _) = self { args.removePeer(peerId) }
})
case let .notice(text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
}
}
}
private final class ProtectedChatsArguments {
let context: AccountContext
let toggleEnabled: (Bool) -> Void
let toggleUseDevicePasscode: (Bool) -> Void
let setCustomPasscode: () -> Void
let addChat: () -> Void
let removePeer: (Int64) -> Void
init(context: AccountContext, toggleEnabled: @escaping (Bool) -> Void, toggleUseDevicePasscode: @escaping (Bool) -> Void, setCustomPasscode: @escaping () -> Void, addChat: @escaping () -> Void, removePeer: @escaping (Int64) -> Void) {
self.context = context
self.toggleEnabled = toggleEnabled
self.toggleUseDevicePasscode = toggleUseDevicePasscode
self.setCustomPasscode = setCustomPasscode
self.addChat = addChat
self.removePeer = removePeer
}
}
public func protectedChatsSettingsController(context: AccountContext) -> ViewController {
let lang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
let title = lang == "ru" ? "Пароль для чатов" : "Password for chats"
let statePromise = Promise<[(Int64, String)]>()
let peerTitles: [(Int64, String)] = ProtectedChatsStore.protectedPeerIds.map { ($0, "Chat \($0)") }
statePromise.set(.single(peerTitles))
var pushControllerImpl: ((ViewController) -> Void)?
let arguments = ProtectedChatsArguments(
context: context,
toggleEnabled: { value in
ProtectedChatsStore.isEnabled = value
},
toggleUseDevicePasscode: { value in
ProtectedChatsStore.useDevicePasscode = value
},
setCustomPasscode: {
let setup = PasscodeSetupController(context: context, mode: .setup(change: false, .digits6))
setup.complete = { passcode, _ in
ProtectedChatsStore.setCustomPasscode(passcode)
ProtectedChatsStore.useDevicePasscode = false
_ = (setup.navigationController as? NavigationController)?.popViewController(animated: true)
}
pushControllerImpl?(setup)
},
addChat: {
let filter: ChatListNodePeersFilter = [.onlyWriteable, .excludeDisabled, .doNotSearchMessages]
let controller = context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(
context: context,
filter: filter,
hasContactSelector: false,
hasGlobalSearch: true,
title: lang == "ru" ? "Выберите чат" : "Select chat"
))
controller.peerSelected = { [weak controller] peer, _ in
let peerId = peer.id.toInt64()
ProtectedChatsStore.addProtectedPeer(peerId)
statePromise.set(.single(ProtectedChatsStore.protectedPeerIds.map { ($0, "Chat \($0)") }))
_ = (controller?.navigationController as? NavigationController)?.popViewController(animated: true)
}
pushControllerImpl?(controller)
},
removePeer: { peerId in
ProtectedChatsStore.removeProtectedPeer(peerId)
statePromise.set(.single(ProtectedChatsStore.protectedPeerIds.map { ($0, "Chat \($0)") }))
}
)
let signal = combineLatest(
context.sharedContext.presentationData,
statePromise.get()
)
|> map { presentationData, peerTitles -> (ItemListControllerState, (ItemListNodeState, ProtectedChatsArguments)) in
let enabled = ProtectedChatsStore.isEnabled
let useDevice = ProtectedChatsStore.useDevicePasscode
let lang = presentationData.strings.baseLanguageCode
var entries: [ProtectedChatsEntry] = []
entries.append(.enabled(lang == "ru" ? "Пароль для чатов" : "Password for chats", enabled))
if enabled {
entries.append(.useDevicePasscode(lang == "ru" ? "Использовать пароль Telegram" : "Use Telegram passcode", useDevice))
if !useDevice {
entries.append(.setCustomPasscode(lang == "ru" ? "Установить отдельный пароль" : "Set separate passcode"))
}
entries.append(.notice(lang == "ru" ? "При открытии выбранных чатов будет запрашиваться пароль." : "Opening selected chats will require passcode."))
}
entries.append(.addChat(lang == "ru" ? "Добавить чат" : "Add chat"))
for (id, t) in peerTitles.sorted(by: { $0.0 < $1.0 }) {
entries.append(.protectedPeer(id: id, title: t))
}
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(title),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
footerItem: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
let signalTyped: Signal<(ItemListControllerState, (ItemListNodeState, ProtectedChatsArguments)), NoError> = signal
let controller = ItemListController(context: context, state: signalTyped)
pushControllerImpl = { [weak controller] (vc: ViewController) in
(controller?.navigationController as? NavigationController)?.pushViewController(vc)
}
return controller
}
@@ -0,0 +1,126 @@
// MARK: Swiftgram - Password for selected chats/folders
import Foundation
import Security
private let enabledKey = "sg_protected_chats_enabled"
private let peerIdsKey = "sg_protected_chat_peer_ids"
private let folderIdsKey = "sg_protected_folder_ids"
private let useDevicePasscodeKey = "sg_protected_chats_use_device_passcode"
private let serviceName = "SwiftgramProtectedChats"
private let customPasscodeAccount = "chats"
public enum ProtectedChatsStore {
public static var isEnabled: Bool {
get { UserDefaults.standard.bool(forKey: enabledKey) }
set { UserDefaults.standard.set(newValue, forKey: enabledKey) }
}
public static var useDevicePasscode: Bool {
get { UserDefaults.standard.object(forKey: useDevicePasscodeKey) as? Bool ?? true }
set { UserDefaults.standard.set(newValue, forKey: useDevicePasscodeKey) }
}
public static var protectedPeerIds: Set<Int64> {
get {
let list = UserDefaults.standard.array(forKey: peerIdsKey) as? [Int64] ?? []
return Set(list)
}
set {
UserDefaults.standard.set(Array(newValue), forKey: peerIdsKey)
}
}
public static var protectedFolderIds: Set<Int32> {
get {
let list = UserDefaults.standard.array(forKey: folderIdsKey) as? [Int32] ?? []
return Set(list)
}
set {
UserDefaults.standard.set(Array(newValue), forKey: folderIdsKey)
}
}
public static func addProtectedPeer(_ peerId: Int64) {
var set = protectedPeerIds
set.insert(peerId)
protectedPeerIds = set
}
public static func removeProtectedPeer(_ peerId: Int64) {
var set = protectedPeerIds
set.remove(peerId)
protectedPeerIds = set
}
public static func addProtectedFolder(_ folderId: Int32) {
var set = protectedFolderIds
set.insert(folderId)
protectedFolderIds = set
}
public static func removeProtectedFolder(_ folderId: Int32) {
var set = protectedFolderIds
set.remove(folderId)
protectedFolderIds = set
}
public static func isProtected(peerId: Int64) -> Bool {
isEnabled && protectedPeerIds.contains(peerId)
}
public static func isProtected(folderId: Int32) -> Bool {
isEnabled && protectedFolderIds.contains(folderId)
}
// MARK: - Custom passcode (when not using device passcode)
public static func setCustomPasscode(_ passcode: String) {
let data = passcode.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: customPasscodeAccount
]
var addQuery = query
addQuery[kSecValueData as String] = data
var status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecDuplicateItem {
SecItemDelete(query as CFDictionary)
status = SecItemAdd(addQuery as CFDictionary, nil)
}
}
public static func customPasscodeMatches(_ passcode: String) -> Bool {
guard let stored = getCustomPasscode() else { return false }
return stored == passcode
}
public static func hasCustomPasscode() -> Bool {
getCustomPasscode() != nil
}
public static func removeCustomPasscode() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: customPasscodeAccount
]
SecItemDelete(query as CFDictionary)
}
private static func getCustomPasscode() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: customPasscodeAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data, let string = String(data: data, encoding: .utf8) else {
return nil
}
return string
}
}
+13
View File
@@ -0,0 +1,13 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "DoubleBottom",
module_name = "DoubleBottom",
srcs = glob(["Sources/**/*.swift"]),
copts = [
"-warnings-as-errors",
],
deps = [
],
visibility = ["//visibility:public"],
)
@@ -0,0 +1,116 @@
// MARK: Swiftgram - Keychain storage for hidden-account passcodes (Double Bottom)
import Foundation
import Security
private let serviceName = "SwiftgramDoubleBottom"
/// Key for the single "secret" passcode (second password). When user unlocks with this, only one account is shown.
private let secretPasscodeAccountKey = "secret"
public enum DoubleBottomPasscodeStore {
// MARK: - Secret passcode (second password -> show only 1 account)
public static func setSecretPasscode(_ passcode: String) {
let data = passcode.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: secretPasscodeAccountKey
]
var addQuery = query
addQuery[kSecValueData as String] = data
var status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecDuplicateItem {
SecItemDelete(query as CFDictionary)
status = SecItemAdd(addQuery as CFDictionary, nil)
}
}
public static func secretPasscodeMatches(_ passcode: String) -> Bool {
guard let stored = secretPasscode() else { return false }
return stored == passcode
}
public static func hasSecretPasscode() -> Bool {
return secretPasscode() != nil
}
public static func removeSecretPasscode() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: secretPasscodeAccountKey
]
SecItemDelete(query as CFDictionary)
}
private static func secretPasscode() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: secretPasscodeAccountKey,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data, let string = String(data: data, encoding: .utf8) else {
return nil
}
return string
}
// MARK: - Per-account passcodes (hidden accounts)
public static func setPasscode(_ passcode: String, forAccountId accountId: Int64) {
let account = "\(accountId)"
let data = passcode.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: account
]
var addQuery = query
addQuery[kSecValueData as String] = data
var status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecDuplicateItem {
SecItemDelete(query as CFDictionary)
status = SecItemAdd(addQuery as CFDictionary, nil)
}
}
public static func passcode(forAccountId accountId: Int64) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: "\(accountId)",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data, let string = String(data: data, encoding: .utf8) else {
return nil
}
return string
}
public static func removePasscode(forAccountId accountId: Int64) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: "\(accountId)"
]
SecItemDelete(query as CFDictionary)
}
/// Returns the account id whose passcode matches the given value, or nil.
public static func accountId(matchingPasscode passcode: String, candidateIds: [Int64]) -> Int64? {
for id in candidateIds {
if Self.passcode(forAccountId: id) == passcode {
return id
}
}
return nil
}
}
@@ -0,0 +1,14 @@
// MARK: Swiftgram - Tracks whether the user unlocked with the "secret" passcode (Double Bottom)
import Foundation
public enum DoubleBottomViewingSecretStore {
private static let key = "DoubleBottomViewingWithSecretPasscode"
public static func isViewingWithSecretPasscode() -> Bool {
return UserDefaults.standard.bool(forKey: key)
}
public static func setViewingWithSecretPasscode(_ value: Bool) {
UserDefaults.standard.set(value, forKey: key)
}
}
@@ -0,0 +1,38 @@
// MARK: - GLEGram Double Bottom
// From Nicegram NGData/Sources/NGSettings.swift - only SystemNGSettings for Double Bottom
import Foundation
public class SystemNGSettings {
let UD = UserDefaults.standard
public init() {}
public var dbReset: Bool {
get {
return UD.bool(forKey: "ng_db_reset")
}
set {
UD.set(newValue, forKey: "ng_db_reset")
}
}
public var isDoubleBottomOn: Bool {
get {
return UD.bool(forKey: "isDoubleBottomOn")
}
set {
UD.set(newValue, forKey: "isDoubleBottomOn")
}
}
public var inDoubleBottom: Bool {
get {
return UD.bool(forKey: "inDoubleBottom")
}
set {
UD.set(newValue, forKey: "inDoubleBottom")
}
}
}
public var VarSystemNGSettings = SystemNGSettings()
+48
View File
@@ -0,0 +1,48 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "GLESettingsUI",
module_name = "GLESettingsUI",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//GLEGram/ChatPassword:ChatPassword",
"//GLEGram/DoubleBottom:DoubleBottom",
"//GLEGram/SGDeletedMessages:SGDeletedMessages",
"//GLEGram/SGFakeLocation:SGFakeLocation",
"//GLEGram/SGSupporters:SGSupporters",
"//GLEGram/VoiceMorpher:VoiceMorpher",
"//Swiftgram/SGItemListUI:SGItemListUI",
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGStrings:SGStrings",
"//Swiftgram/SGAPIToken:SGAPIToken",
"//Swiftgram/SGSwiftUI:SGSwiftUI",
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
"//submodules/Display:Display",
"//submodules/Postbox:Postbox",
"//submodules/TelegramCore:TelegramCore",
"//submodules/MtProtoKit:MtProtoKit",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
"//submodules/ItemListUI:ItemListUI",
"//submodules/PresentationDataUtils:PresentationDataUtils",
"//submodules/OverlayStatusController:OverlayStatusController",
"//submodules/AccountContext:AccountContext",
"//submodules/AppBundle:AppBundle",
"//submodules/UndoUI:UndoUI",
"//submodules/LegacyUI:LegacyUI",
"//submodules/LocalizedPeerData:LocalizedPeerData",
"//submodules/PasscodeUI:PasscodeUI",
"//submodules/SettingsUI:SettingsUI",
"//submodules/TelegramStringFormatting:TelegramStringFormatting",
],
visibility = [
"//visibility:public",
],
)
+175
View File
@@ -0,0 +1,175 @@
// MARK: Swiftgram Extract .dylib from .deb packages (Cydia-style tweaks)
import Foundation
import Compression
/// Result of installing a .deb: package name/version (if parsed) and list of installed .dylib filenames.
public struct DebInstallResult {
public let packageName: String?
public let packageVersion: String?
public let installedDylibs: [String]
}
/// Extracts .dylib files from a .deb and installs them into TweakLoader's directory.
public enum DebExtractor {
private static let arMagic = "!<arch>\n"
private static let arMagicData = Data(arMagic.utf8)
/// Install .deb: extract data archive, find all .dylib, copy to Tweaks directory.
/// Supports data.tar.gz and data.tar.lzma. Returns installed dylib filenames or throws.
public static func installDeb(from url: URL, tweaksDirectory: URL) throws -> DebInstallResult {
let data = try Data(contentsOf: url)
let (controlName, controlVersion) = parseControl(from: data)
let dataTar = try extractDataTar(from: data)
let (tempDir, dylibEntries) = try listDylibsInTar(data: dataTar)
defer { try? FileManager.default.removeItem(at: tempDir) }
let fileManager = FileManager.default
try fileManager.createDirectory(at: tweaksDirectory, withIntermediateDirectories: true)
var installed: [String] = []
for entry in dylibEntries {
let name = (entry.path as NSString).lastPathComponent
guard name.lowercased().hasSuffix(".dylib") else { continue }
let dest = tweaksDirectory.appendingPathComponent(name)
if fileManager.fileExists(atPath: dest.path) { try? fileManager.removeItem(at: dest) }
try fileManager.copyItem(at: entry.url, to: dest)
installed.append(name)
}
if installed.isEmpty {
throw NSError(domain: "DebExtractor", code: 2, userInfo: [NSLocalizedDescriptionKey: "No .dylib files found in the .deb package"])
}
return DebInstallResult(packageName: controlName, packageVersion: controlVersion, installedDylibs: installed)
}
/// Parse ar archive and return raw content of member whose name starts with `prefix`.
private static func readArMember(data: Data, namePrefix: String) -> Data? {
guard data.count >= 8, data.prefix(8).elementsEqual(arMagicData) else { return nil }
var offset = 8
while offset + 60 <= data.count {
let header = data.subdata(in: offset ..< offset + 60)
guard let name = String(data: header.prefix(16), encoding: .ascii)?.trimmingCharacters(in: CharacterSet.whitespaces.union(CharacterSet(charactersIn: "\0"))),
let sizeStr = String(data: header.subdata(in: 48 ..< 58), encoding: .ascii)?.trimmingCharacters(in: .whitespaces),
let size = Int(sizeStr, radix: 10), size >= 0 else {
break
}
offset += 60
if name == "/" || name.isEmpty { offset += size; if size % 2 != 0 { offset += 1 }; continue }
if name.hasPrefix(namePrefix) {
guard offset + size <= data.count else { return nil }
return data.subdata(in: offset ..< offset + size)
}
offset += size
if offset % 2 != 0 { offset += 1 }
}
return nil
}
/// Parse control.tar.gz to get Package and Version (optional).
private static func parseControl(from debData: Data) -> (name: String?, version: String?) {
guard let controlTar = readArMember(data: debData, namePrefix: "control.tar") else { return (nil, nil) }
let decompressed: Data
if controlTar.prefix(2) == Data([0x1f, 0x8b]) {
guard let d = decompressGzip(controlTar) else { return (nil, nil) }
decompressed = d
} else {
decompressed = controlTar
}
guard let controlFile = readFileFromTar(data: decompressed, nameSuffix: "control") else { return (nil, nil) }
guard let str = String(data: controlFile, encoding: .utf8) else { return (nil, nil) }
var name: String?
var version: String?
for line in str.components(separatedBy: .newlines) {
if line.hasPrefix("Package:") { name = line.dropFirst(8).trimmingCharacters(in: .whitespaces) }
if line.hasPrefix("Version:") { version = line.dropFirst(8).trimmingCharacters(in: .whitespaces) }
}
return (name, version)
}
/// Extract data.tar.* from .deb and decompress to raw tar.
private static func extractDataTar(from debData: Data) throws -> Data {
guard let dataMember = readArMember(data: debData, namePrefix: "data.tar") else {
throw NSError(domain: "DebExtractor", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid .deb: no data.tar found"])
}
if dataMember.prefix(2) == Data([0x1f, 0x8b]) {
guard let d = decompressGzip(dataMember) else {
throw NSError(domain: "DebExtractor", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress data.tar.gz"])
}
return d
}
if dataMember.prefix(3).elementsEqual(Data([0x5d, 0x00, 0x00])) || dataMember.prefix(1) == Data([0x5d]) {
guard let d = decompressLzma(dataMember) else {
throw NSError(domain: "DebExtractor", code: 4, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress data.tar.lzma"])
}
return d
}
return dataMember
}
/// List .dylib entries in tar; extract each to a temp file and return (tempDir, entries). Caller must remove tempDir after copying.
private static func listDylibsInTar(data: Data) throws -> (tempDir: URL, entries: [(path: String, url: URL)]) {
var results: [(path: String, url: URL)] = []
let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
var offset = 0
while offset + 512 <= data.count {
let header = data.subdata(in: offset ..< offset + 512)
if header.prefix(257).allSatisfy({ $0 == 0 }) { break }
guard let name = String(data: header.prefix(100), encoding: .ascii)?.trimmingCharacters(in: CharacterSet(charactersIn: "\0")) else {
offset += 512
continue
}
let sizeStr = String(data: header.subdata(in: 124 ..< 136), encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? "0"
let size = Int(sizeStr, radix: 8) ?? 0
offset += 512
let contentStart = offset
offset += (size + 511) / 512 * 512
guard name.hasSuffix(".dylib"), size > 0, contentStart + size <= data.count else { continue }
let content = data.subdata(in: contentStart ..< contentStart + size)
let base = (name as NSString).lastPathComponent
let tmpFile = tmpDir.appendingPathComponent(base)
try content.write(to: tmpFile)
results.append((name, tmpFile))
}
return (tmpDir, results)
}
/// Read first file from tar that has given name suffix (e.g. "control").
private static func readFileFromTar(data: Data, nameSuffix: String) -> Data? {
var offset = 0
while offset + 512 <= data.count {
let header = data.subdata(in: offset ..< offset + 512)
if header.prefix(257).allSatisfy({ $0 == 0 }) { break }
guard let name = String(data: header.prefix(100), encoding: .ascii)?.trimmingCharacters(in: CharacterSet(charactersIn: "\0")),
name.hasSuffix(nameSuffix) else {
let sizeStr = String(data: header.subdata(in: 124 ..< 136), encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? "0"
let size = Int(sizeStr, radix: 8) ?? 0
offset += 512 + (size + 511) / 512 * 512
continue
}
let sizeStr = String(data: header.subdata(in: 124 ..< 136), encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? "0"
let size = Int(sizeStr, radix: 8) ?? 0
offset += 512
guard offset + size <= data.count else { return nil }
return data.subdata(in: offset ..< offset + size)
}
return nil
}
private static func decompressGzip(_ data: Data) -> Data? {
return decompress(data, algorithm: COMPRESSION_ZLIB)
}
/// LZMA (e.g. data.tar.lzma).
private static func decompressLzma(_ data: Data) -> Data? {
return decompress(data, algorithm: COMPRESSION_LZMA)
}
private static func decompress(_ data: Data, algorithm: compression_algorithm) -> Data? {
let destSize = 16 * 1024 * 1024
let dest = UnsafeMutablePointer<UInt8>.allocate(capacity: destSize)
defer { dest.deallocate() }
let decoded = data.withUnsafeBytes { (src: UnsafeRawBufferPointer) -> Int in
compression_decode_buffer(dest, destSize, src.bindMemory(to: UInt8.self).baseAddress!, data.count, nil, algorithm)
}
guard decoded > 0 else { return nil }
return Data(bytes: dest, count: decoded)
}
}
@@ -0,0 +1,210 @@
// MARK: Swiftgram - Double Bottom (full logic from Nicegram NGDoubleBottom/DoubleBottomListController)
// Ref: https://github.com/nicegram/Nicegram-iOS/blob/master/Nicegram/NGDoubleBottom/Sources/DoubleBottomListController.swift
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import PasscodeUI
import DoubleBottom
import SGSimpleSettings
import TelegramStringFormatting
// MARK: - GLEGram
// MARK: - Section (Nicegram: DoubleBottomControllerSection)
private enum DoubleBottomControllerSection: Int32 {
case isOn = 0
}
// MARK: - Entry (Nicegram: isOn + info)
private enum DoubleBottomEntry: ItemListNodeEntry {
case isOn(String, Bool, Bool) // title, value, enabled
case info(String)
var section: ItemListSectionId { DoubleBottomControllerSection.isOn.rawValue }
var stableId: Int32 {
switch self {
case .isOn: return 1000
case .info: return 1100
}
}
static func < (lhs: DoubleBottomEntry, rhs: DoubleBottomEntry) -> Bool {
lhs.stableId < rhs.stableId
}
static func == (lhs: DoubleBottomEntry, rhs: DoubleBottomEntry) -> Bool {
switch (lhs, rhs) {
case let (.isOn(lhsText, lhsBool, _), .isOn(rhsText, rhsBool, _)):
return lhsText == rhsText && lhsBool == rhsBool
case let (.info(lhsText), .info(rhsText)):
return lhsText == rhsText
default:
return false
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! DoubleBottomArguments
switch self {
case let .isOn(text, value, enabled):
return ItemListSwitchItem(
presentationData: presentationData,
title: text,
value: value,
enabled: enabled,
sectionId: section,
style: .blocks,
updated: { value in
args.updated(value)
}
)
case let .info(text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
}
}
}
// MARK: - Arguments (Nicegram: DoubleBottomControllerArguments)
private final class DoubleBottomArguments {
let context: AccountContext
let updated: (Bool) -> Void
init(context: AccountContext, updated: @escaping (Bool) -> Void) {
self.context = context
self.updated = updated
}
}
// MARK: - Controller (logic from Nicegram DoubleBottomListController)
public func doubleBottomSettingsController(context: AccountContext) -> ViewController {
let lang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
let title = lang == "ru" ? "Двойное дно" : "Double Bottom"
let toggleTitle = lang == "ru" ? "Двойное дно" : "Double Bottom"
let noticeText = lang == "ru"
? "Скрытые аккаунты и вход по паролю. Разные пароли открывают разные профили."
: "Hidden accounts and passcode access. Different passwords open different profiles."
let arguments = DoubleBottomArguments(context: context, updated: { value in
if value {
SGSimpleSettings.shared.doubleBottomEnabled = true
let setupController = PasscodeSetupController(context: context, mode: .setup(change: false, .digits6))
setupController.complete = { passcode, _ in
DoubleBottomPasscodeStore.setSecretPasscode(passcode)
setupController.dismiss()
}
context.sharedContext.presentGlobalController(setupController, nil)
} else {
SGSimpleSettings.shared.doubleBottomEnabled = false
DoubleBottomPasscodeStore.removeSecretPasscode()
DoubleBottomViewingSecretStore.setViewingWithSecretPasscode(false)
let accountManager = context.sharedContext.accountManager
// Remove secret passcodes from Keychain for previously hidden accounts
let _ = (accountManager.accountRecords()
|> take(1)
|> deliverOnMainQueue).start(next: { view in
for record in view.records where record.attributes.contains(where: { $0.isHiddenAccountAttribute }) {
DoubleBottomPasscodeStore.removePasscode(forAccountId: record.id.int64)
}
})
// Nicegram: single transaction - keep device passcode, remove HiddenAccount from all records
let _ = accountManager.transaction { transaction in
let challengeData = transaction.getAccessChallengeData()
let challenge: PostboxAccessChallengeData
switch challengeData {
case .numericalPassword(let value):
challenge = .numericalPassword(value: value)
case .plaintextPassword(let value):
challenge = .plaintextPassword(value: value)
case .none:
challenge = .none
}
transaction.setAccessChallengeData(challenge)
for record in transaction.getRecords() {
transaction.updateRecord(record.id) { current in
guard let current = current else { return nil }
var attributes = current.attributes
attributes.removeAll { $0.isHiddenAccountAttribute }
return AccountRecord(id: current.id, attributes: attributes, temporarySessionId: current.temporarySessionId)
}
}
}.start()
}
})
let transactionStatus = context.sharedContext.accountManager.transaction { transaction -> (Bool, Bool) in
let records = transaction.getRecords()
let publicCount = records.filter { record in
let attrs = record.attributes
let hiddenOrLoggedOut = attrs.contains(where: { $0.isHiddenAccountAttribute || $0.isLoggedOutAccountAttribute })
return !hiddenOrLoggedOut
}.count
let hasMoreThanOnePublic = publicCount > 1
let hasMainPasscode = transaction.getAccessChallengeData() != .none
return (hasMoreThanOnePublic, hasMainPasscode)
}
let signal: Signal<(ItemListControllerState, (ItemListNodeState, DoubleBottomArguments)), NoError> = combineLatest(context.sharedContext.presentationData, transactionStatus)
|> map { presentationData, contextStatus -> (ItemListControllerState, (ItemListNodeState, DoubleBottomArguments)) in
let isOn = SGSimpleSettings.shared.doubleBottomEnabled
let enabled = isOn || (contextStatus.0 && contextStatus.1)
let entries: [DoubleBottomEntry] = [
.isOn(toggleTitle, isOn, enabled),
.info(noticeText)
]
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(title),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
footerItem: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
return ItemListController(context: context, state: signal)
}
// MARK: - Passcode check (Nicegram: check(passcode:challengeData:) for device passcode validation)
public func doubleBottomCheckPasscode(_ passcode: String, challengeData: PostboxAccessChallengeData) -> Bool {
let passcodeType: PasscodeEntryFieldType
switch challengeData {
case let .numericalPassword(value):
passcodeType = value.count == 6 ? .digits6 : .digits4
default:
passcodeType = .alphanumeric
}
switch challengeData {
case .none:
return true
case let .numericalPassword(code):
if passcodeType == .alphanumeric {
return false
}
return passcode == normalizeArabicNumeralString(code, type: .western)
case let .plaintextPassword(code):
if passcodeType != .alphanumeric {
return false
}
return passcode == code
}
}
@@ -0,0 +1,259 @@
// MARK: Swiftgram Fake Profile settings: target user, name/username/phone/ID, badges
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGItemListUI
import SGSimpleSettings
private enum FakeProfileSection: Int32, SGItemListSection {
case targetUser = 0
case personalData
case badges
}
private enum FakeProfileEntry: ItemListNodeEntry {
case targetHeader(id: Int, text: String)
case targetUserId(id: Int, text: String, placeholder: String)
case targetNotice(id: Int, text: String)
case personalHeader(id: Int, text: String)
case firstName(id: Int, text: String, placeholder: String)
case lastName(id: Int, text: String, placeholder: String)
case username(id: Int, text: String, placeholder: String)
case phone(id: Int, text: String, placeholder: String)
case fakeId(id: Int, text: String, placeholder: String)
case personalNotice(id: Int, text: String)
case badgesHeader(id: Int, text: String)
case premium(id: Int, title: String, subtext: String?, value: Bool)
case verified(id: Int, title: String, subtext: String?, value: Bool)
case scam(id: Int, title: String, subtext: String?, value: Bool)
case fake(id: Int, title: String, subtext: String?, value: Bool)
case support(id: Int, title: String, subtext: String?, value: Bool)
case bot(id: Int, title: String, subtext: String?, value: Bool)
case badgesNotice(id: Int, text: String)
var id: Int { stableId }
var section: ItemListSectionId {
switch self {
case .targetHeader, .targetUserId, .targetNotice: return FakeProfileSection.targetUser.rawValue
case .personalHeader, .firstName, .lastName, .username, .phone, .fakeId, .personalNotice: return FakeProfileSection.personalData.rawValue
default: return FakeProfileSection.badges.rawValue
}
}
var stableId: Int {
switch self {
case .targetHeader(let i, _), .targetUserId(let i, _, _), .targetNotice(let i, _),
.personalHeader(let i, _), .firstName(let i, _, _), .lastName(let i, _, _), .username(let i, _, _),
.phone(let i, _, _), .fakeId(let i, _, _), .personalNotice(let i, _),
.badgesHeader(let i, _), .premium(let i, _, _, _), .verified(let i, _, _, _), .scam(let i, _, _, _),
.fake(let i, _, _, _), .support(let i, _, _, _), .bot(let i, _, _, _), .badgesNotice(let i, _): return i
}
}
static func < (lhs: FakeProfileEntry, rhs: FakeProfileEntry) -> Bool { lhs.stableId < rhs.stableId }
static func == (lhs: FakeProfileEntry, rhs: FakeProfileEntry) -> Bool {
switch (lhs, rhs) {
case let (.targetHeader(a, t1), .targetHeader(b, t2)): return a == b && t1 == t2
case let (.targetUserId(a, t1, p1), .targetUserId(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.targetNotice(a, t1), .targetNotice(b, t2)): return a == b && t1 == t2
case let (.personalHeader(a, t1), .personalHeader(b, t2)): return a == b && t1 == t2
case let (.firstName(a, t1, p1), .firstName(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.lastName(a, t1, p1), .lastName(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.username(a, t1, p1), .username(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.phone(a, t1, p1), .phone(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.fakeId(a, t1, p1), .fakeId(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.personalNotice(a, t1), .personalNotice(b, t2)): return a == b && t1 == t2
case let (.badgesHeader(a, t1), .badgesHeader(b, t2)): return a == b && t1 == t2
case let (.premium(a, t1, s1, v1), .premium(b, t2, s2, v2)): return a == b && t1 == t2 && s1 == s2 && v1 == v2
case let (.verified(a, t1, s1, v1), .verified(b, t2, s2, v2)): return a == b && t1 == t2 && s1 == s2 && v1 == v2
case let (.scam(a, t1, s1, v1), .scam(b, t2, s2, v2)): return a == b && t1 == t2 && s1 == s2 && v1 == v2
case let (.fake(a, t1, s1, v1), .fake(b, t2, s2, v2)): return a == b && t1 == t2 && s1 == s2 && v1 == v2
case let (.support(a, t1, s1, v1), .support(b, t2, s2, v2)): return a == b && t1 == t2 && s1 == s2 && v1 == v2
case let (.bot(a, t1, s1, v1), .bot(b, t2, s2, v2)): return a == b && t1 == t2 && s1 == s2 && v1 == v2
case let (.badgesNotice(a, t1), .badgesNotice(b, t2)): return a == b && t1 == t2
default: return false
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let theme = presentationData.theme
let args = arguments as! FakeProfileArguments
switch self {
case .targetHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: section)
case .targetUserId(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: "ID", textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .regular(capitalization: false, autocorrection: false), clearType: .always, sectionId: section, textUpdated: { args.updateTargetUserId($0) }, action: {})
case .targetNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
case .personalHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: section)
case .firstName(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: (presentationData.strings.baseLanguageCode == "ru" ? "Имя" : "First name"), textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .regular(capitalization: true, autocorrection: false), clearType: .always, sectionId: section, textUpdated: { args.updateFirstName($0) }, action: {})
case .lastName(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: (presentationData.strings.baseLanguageCode == "ru" ? "Фамилия" : "Last name"), textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .regular(capitalization: true, autocorrection: false), clearType: .always, sectionId: section, textUpdated: { args.updateLastName($0) }, action: {})
case .username(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: (presentationData.strings.baseLanguageCode == "ru" ? "Юзернейм (без @)" : "Username (no @)"), textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .regular(capitalization: false, autocorrection: false), clearType: .always, sectionId: section, textUpdated: { args.updateUsername($0) }, action: {})
case .phone(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: (presentationData.strings.baseLanguageCode == "ru" ? "Телефон (без +)" : "Phone (no +)"), textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .number, clearType: .always, sectionId: section, textUpdated: { args.updatePhone($0) }, action: {})
case .fakeId(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: "Telegram ID", textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .number, clearType: .always, sectionId: section, textUpdated: { args.updateFakeId($0) }, action: {})
case .personalNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
case .badgesHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: section)
case .premium(_, let title, let subtext, let value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, text: subtext, value: value, sectionId: section, style: .blocks, updated: { args.updatePremium($0) })
case .verified(_, let title, let subtext, let value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, text: subtext, value: value, sectionId: section, style: .blocks, updated: { args.updateVerified($0) })
case .scam(_, let title, let subtext, let value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, text: subtext, value: value, sectionId: section, style: .blocks, updated: { args.updateScam($0) })
case .fake(_, let title, let subtext, let value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, text: subtext, value: value, sectionId: section, style: .blocks, updated: { args.updateFake($0) })
case .support(_, let title, let subtext, let value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, text: subtext, value: value, sectionId: section, style: .blocks, updated: { args.updateSupport($0) })
case .bot(_, let title, let subtext, let value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, text: subtext, value: value, sectionId: section, style: .blocks, updated: { args.updateBot($0) })
case .badgesNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
}
}
}
private final class FakeProfileArguments {
let reload: () -> Void
init(reload: @escaping () -> Void) { self.reload = reload }
func updateTargetUserId(_ value: String) {
SGSimpleSettings.shared.fakeProfileTargetUserId = value
reload()
}
func updateFirstName(_ value: String) {
SGSimpleSettings.shared.fakeProfileFirstName = value
reload()
}
func updateLastName(_ value: String) {
SGSimpleSettings.shared.fakeProfileLastName = value
reload()
}
func updateUsername(_ value: String) {
SGSimpleSettings.shared.fakeProfileUsername = value
reload()
}
func updatePhone(_ value: String) {
SGSimpleSettings.shared.fakeProfilePhone = value
reload()
}
func updateFakeId(_ value: String) {
SGSimpleSettings.shared.fakeProfileId = value
reload()
}
func updatePremium(_ value: Bool) {
SGSimpleSettings.shared.fakeProfilePremium = value
reload()
}
func updateVerified(_ value: Bool) {
SGSimpleSettings.shared.fakeProfileVerified = value
reload()
}
func updateScam(_ value: Bool) {
SGSimpleSettings.shared.fakeProfileScam = value
reload()
}
func updateFake(_ value: Bool) {
SGSimpleSettings.shared.fakeProfileFake = value
reload()
}
func updateSupport(_ value: Bool) {
SGSimpleSettings.shared.fakeProfileSupport = value
reload()
}
func updateBot(_ value: Bool) {
SGSimpleSettings.shared.fakeProfileBot = value
reload()
}
}
private func fakeProfileEntries(presentationData: PresentationData) -> [FakeProfileEntry] {
let lang = presentationData.strings.baseLanguageCode
let s = SGSimpleSettings.shared
var entries: [FakeProfileEntry] = []
var id = 0
entries.append(.targetHeader(id: id, text: lang == "ru" ? "ЦЕЛЕВОЙ ПОЛЬЗОВАТЕЛЬ" : "TARGET USER"))
id += 1
entries.append(.targetUserId(id: id, text: s.fakeProfileTargetUserId, placeholder: lang == "ru" ? "Оставьте пустым для своего профиля" : "Leave empty for your profile"))
id += 1
entries.append(.targetNotice(id: id, text: lang == "ru" ? "Чтобы узнать ID, используйте @userinfobot" : "Use @userinfobot to get user ID"))
id += 1
entries.append(.personalHeader(id: id, text: lang == "ru" ? "ЛИЧНЫЕ ДАННЫЕ" : "PERSONAL DATA"))
id += 1
entries.append(.firstName(id: id, text: s.fakeProfileFirstName, placeholder: lang == "ru" ? "Имя" : "First name"))
id += 1
entries.append(.lastName(id: id, text: s.fakeProfileLastName, placeholder: lang == "ru" ? "Фамилия" : "Last name"))
id += 1
entries.append(.username(id: id, text: s.fakeProfileUsername, placeholder: lang == "ru" ? "без @" : "no @"))
id += 1
entries.append(.phone(id: id, text: s.fakeProfilePhone, placeholder: lang == "ru" ? "без +" : "no +"))
id += 1
entries.append(.fakeId(id: id, text: s.fakeProfileId, placeholder: lang == "ru" ? "Визуально изменить ID" : "Override displayed ID"))
id += 1
entries.append(.personalNotice(id: id, text: lang == "ru" ? "Пустые поля — реальные данные." : "Empty = real data."))
id += 1
entries.append(.badgesHeader(id: id, text: lang == "ru" ? "СТАТУСЫ И ЗНАЧКИ" : "BADGES"))
id += 1
let premiumTitle = lang == "ru" ? "Premium" : "Premium"
let premiumSub = lang == "ru" ? "Визуально добавляет иконку Premium." : "Shows Premium badge."
entries.append(.premium(id: id, title: premiumTitle, subtext: premiumSub, value: s.fakeProfilePremium))
id += 1
let verifiedTitle = lang == "ru" ? "Верификация" : "Verified"
let verifiedSub = lang == "ru" ? "Визуально добавляет галочку." : "Shows verification badge."
entries.append(.verified(id: id, title: verifiedTitle, subtext: verifiedSub, value: s.fakeProfileVerified))
id += 1
let scamTitle = lang == "ru" ? "Scam" : "Scam"
let scamSub = lang == "ru" ? "Помечает как скам." : "Marks as scam."
entries.append(.scam(id: id, title: scamTitle, subtext: scamSub, value: s.fakeProfileScam))
id += 1
let fakeTitle = lang == "ru" ? "Fake" : "Fake"
let fakeSub = lang == "ru" ? "Помечает как фейк." : "Marks as fake."
entries.append(.fake(id: id, title: fakeTitle, subtext: fakeSub, value: s.fakeProfileFake))
id += 1
let supportTitle = lang == "ru" ? "Support" : "Support"
let supportSub = lang == "ru" ? "Официальная поддержка." : "Official support badge."
entries.append(.support(id: id, title: supportTitle, subtext: supportSub, value: s.fakeProfileSupport))
id += 1
let botTitle = lang == "ru" ? "Бот" : "Bot"
let botSub = lang == "ru" ? "Помечает как бота." : "Marks as bot."
entries.append(.bot(id: id, title: botTitle, subtext: botSub, value: s.fakeProfileBot))
id += 1
entries.append(.badgesNotice(id: id, text: lang == "ru" ? "Для полного применения может потребоваться перезапуск." : "Restart may be required for full effect."))
return entries
}
/// Fake Profile settings: target user ID, first/last name, username, phone, fake ID, badge toggles.
public func FakeProfileSettingsController(context: AccountContext, onSave: @escaping () -> Void) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
let arguments = FakeProfileArguments(reload: { reloadPromise.set(true) })
let signal = combineLatest(reloadPromise.get(), context.sharedContext.presentationData)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, FakeProfileArguments)) in
let lang = presentationData.strings.baseLanguageCode
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(lang == "ru" ? "Настройки Fake Profile" : "Fake Profile settings"),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = fakeProfileEntries(presentationData: presentationData)
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: nil, initialScrollToItem: nil)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
return controller
}
@@ -0,0 +1,83 @@
// MARK: Swiftgram Local stars balance: edit amount
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
private enum FeelRichAmountEntry: ItemListNodeEntry {
case header(id: Int, text: String)
case amount(id: Int, text: String, placeholder: String)
var id: Int { stableId }
var section: ItemListSectionId { 0 }
var stableId: Int {
switch self {
case .header(let id, _), .amount(let id, _, _): return id
}
}
static func < (lhs: FeelRichAmountEntry, rhs: FeelRichAmountEntry) -> Bool { lhs.stableId < rhs.stableId }
static func == (lhs: FeelRichAmountEntry, rhs: FeelRichAmountEntry) -> Bool {
switch (lhs, rhs) {
case let (.header(a, t1), .header(b, t2)): return a == b && t1 == t2
case let (.amount(a, t1, p1), .amount(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
default: return false
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let theme = presentationData.theme
let args = arguments as! FeelRichAmountArguments
switch self {
case .header(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: section)
case .amount(_, let text, let placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: (presentationData.strings.baseLanguageCode == "ru" ? "Сумма (звёзды)" : "Amount (stars)"), textColor: theme.list.itemPrimaryTextColor), text: text, placeholder: placeholder, type: .number, clearType: .always, sectionId: section, textUpdated: { args.updateAmount($0) }, action: {})
}
}
}
private final class FeelRichAmountArguments {
let reload: () -> Void
init(reload: @escaping () -> Void) { self.reload = reload }
func updateAmount(_ value: String) {
SGSimpleSettings.shared.feelRichStarsAmount = value
reload()
}
}
private func feelRichAmountEntries(presentationData: PresentationData) -> [FeelRichAmountEntry] {
let lang = presentationData.strings.baseLanguageCode
var entries: [FeelRichAmountEntry] = []
var id = 0
entries.append(.header(id: id, text: lang == "ru" ? "БАЛАНС ЗВЁЗД" : "STARS BALANCE"))
id += 1
entries.append(.amount(id: id, text: SGSimpleSettings.shared.feelRichStarsAmount, placeholder: "1000"))
return entries
}
/// Edit local stars balance amount.
public func FeelRichAmountController(context: AccountContext, onSave: @escaping () -> Void) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
let arguments = FeelRichAmountArguments(reload: { reloadPromise.set(true) })
let signal = combineLatest(reloadPromise.get(), context.sharedContext.presentationData)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, FeelRichAmountArguments)) in
let lang = presentationData.strings.baseLanguageCode
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(lang == "ru" ? "Сумма звёзд" : "Stars amount"),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = feelRichAmountEntries(presentationData: presentationData)
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: nil, initialScrollToItem: nil)
return (controllerState, (listState, arguments))
}
return ItemListController(context: context, state: signal)
}
+205
View File
@@ -0,0 +1,205 @@
// MARK: Swiftgram - Download fonts from the internet (search + list)
import SGSimpleSettings
import Foundation
import UIKit
import CoreText
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import AccountContext
/// Downloaded fonts directory name under Documents/SwiftgramFonts/
private let kDownloadedFontsSubdir = "Downloaded"
/// Register all .ttf files in the downloaded fonts directory so they appear in the font picker.
public func registerAllDownloadedFonts() {
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
let base = documents.appendingPathComponent("SwiftgramFonts", isDirectory: true).appendingPathComponent(kDownloadedFontsSubdir, isDirectory: true)
guard let contents = try? FileManager.default.contentsOfDirectory(at: base, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) else { return }
let ttfUrls = contents.filter { $0.pathExtension.lowercased() == "ttf" }
if ttfUrls.isEmpty { return }
CTFontManagerRegisterFontURLs(ttfUrls as CFArray, .process, true, nil)
}
/// Static list: (display name, .ttf URL). Google Fonts from GitHub raw.
private let kDownloadableFonts: [(name: String, url: String)] = [
("Roboto", "https://github.com/google/fonts/raw/main/apache/roboto/Roboto-Regular.ttf"),
("Roboto Condensed", "https://github.com/google/fonts/raw/main/apache/robotocondensed/RobotoCondensed-Regular.ttf"),
("Open Sans", "https://github.com/google/fonts/raw/main/apache/opensans/OpenSans-Regular.ttf"),
("Lato", "https://github.com/google/fonts/raw/main/ofl/lato/Lato-Regular.ttf"),
("Oswald", "https://github.com/google/fonts/raw/main/ofl/oswald/Oswald-Regular.ttf"),
("Source Sans 3", "https://github.com/google/fonts/raw/main/ofl/sourcesans3/SourceSans3-Regular.ttf"),
("Montserrat", "https://github.com/google/fonts/raw/main/ofl/montserrat/Montserrat-Regular.ttf"),
("Raleway", "https://github.com/google/fonts/raw/main/ofl/raleway/Raleway-Regular.ttf"),
("PT Sans", "https://github.com/google/fonts/raw/main/ofl/ptsans/PT_Sans-Regular.ttf"),
("Merriweather", "https://github.com/google/fonts/raw/main/ofl/merriweather/Merriweather-Regular.ttf"),
("Nunito", "https://github.com/google/fonts/raw/main/ofl/nunito/Nunito-Regular.ttf"),
("Fira Sans", "https://github.com/google/fonts/raw/main/ofl/firasans/FiraSans-Regular.ttf"),
("Ubuntu", "https://github.com/google/fonts/raw/main/ufl/ubuntu/Ubuntu-Regular.ttf"),
("Playfair Display", "https://github.com/google/fonts/raw/main/ofl/playfairdisplay/PlayfairDisplay-Regular.ttf"),
("Oxygen", "https://github.com/google/fonts/raw/main/ofl/oxygen/Oxygen-Regular.ttf"),
("Manrope", "https://github.com/google/fonts/raw/main/ofl/manrope/Manrope-Regular.ttf"),
("Inter", "https://github.com/google/fonts/raw/main/ofl/inter/Inter-Regular.ttf"),
("Poppins", "https://github.com/google/fonts/raw/main/ofl/poppins/Poppins-Regular.ttf"),
("Work Sans", "https://github.com/google/fonts/raw/main/ofl/worksans/WorkSans-Regular.ttf"),
("Rubik", "https://github.com/google/fonts/raw/main/ofl/rubik/Rubik-Regular.ttf"),
]
private enum FontDownloadEntry: ItemListNodeEntry {
case search(entryId: Int, query: String)
case font(entryId: Int, name: String, url: String, isDownloading: Bool, isDownloaded: Bool)
var section: ItemListSectionId { 0 }
var stableId: Int {
switch self {
case .search(let id, _): return id
case .font(let id, _, _, _, _): return id
}
}
var id: Int { stableId }
static func == (lhs: FontDownloadEntry, rhs: FontDownloadEntry) -> Bool {
switch (lhs, rhs) {
case (.search(let id1, let q1), .search(let id2, let q2)): return id1 == id2 && q1 == q2
case (.font(let id1, let n1, _, let d1, let i1), .font(let id2, let n2, _, let d2, let i2)): return id1 == id2 && n1 == n2 && d1 == d2 && i1 == i2
default: return false
}
}
static func < (lhs: FontDownloadEntry, rhs: FontDownloadEntry) -> Bool { lhs.stableId < rhs.stableId }
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! FontDownloadArguments
switch self {
case .search(_, let query):
let placeholder = presentationData.strings.baseLanguageCode == "ru" ? "Поиск шрифта" : "Search font"
return ItemListSingleLineInputItem(
presentationData: presentationData,
title: NSAttributedString(),
text: query,
placeholder: placeholder,
returnKeyType: .search,
spacing: 0,
clearType: .always,
sectionId: section,
textUpdated: { args.updateSearch($0) },
shouldUpdateText: { _ in true },
action: {}
)
case .font(_, let name, let url, let isDownloading, let isDownloaded):
let label: String
if isDownloading {
label = presentationData.strings.baseLanguageCode == "ru" ? "Загрузка…" : "Downloading…"
} else if isDownloaded {
label = presentationData.strings.baseLanguageCode == "ru" ? "Установлен" : "Installed"
} else {
label = ""
}
return ItemListDisclosureItem(
presentationData: presentationData,
title: name,
enabled: !isDownloading,
label: label,
sectionId: section,
style: .blocks,
action: isDownloading ? nil : { args.download(name, url) }
)
}
}
}
private struct FontDownloadArguments {
let updateSearch: (String) -> Void
let download: (String, String) -> Void
}
private struct FontDownloadState: Equatable {
var searchQuery: String
var downloadingNames: Set<String>
var downloadedNames: Set<String>
}
public func FontDownloadController(context: AccountContext, onFontAdded: @escaping () -> Void) -> ViewController {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
var state = FontDownloadState(searchQuery: "", downloadingNames: [], downloadedNames: [])
func downloadedFontsDirectory() -> URL? {
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil }
let base = documents.appendingPathComponent("SwiftgramFonts", isDirectory: true).appendingPathComponent(kDownloadedFontsSubdir, isDirectory: true)
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
return base
}
func isFontDownloaded(displayName: String) -> Bool {
guard let dir = downloadedFontsDirectory() else { return false }
let sanitized = displayName.replacingOccurrences(of: " ", with: "_")
let path = dir.appendingPathComponent(sanitized + ".ttf").path
return FileManager.default.fileExists(atPath: path)
}
let statePromise = ValuePromise<FontDownloadState>(state, ignoreRepeated: true)
let updateState: ((String?, Set<String>?, Set<String>?) -> Void) = { query, downloading, downloaded in
if let q = query { state.searchQuery = q }
if let d = downloading { state.downloadingNames = d }
if let d = downloaded { state.downloadedNames = d }
statePromise.set(state)
}
let arguments = FontDownloadArguments(
updateSearch: { updateState($0, nil, nil) },
download: { name, urlString in
updateState(nil, { var s = state.downloadingNames; s.insert(name); return s }(), nil)
guard let url = URL(string: urlString), let dir = downloadedFontsDirectory() else {
updateState(nil, { var s = state.downloadingNames; s.remove(name); return s }(), nil)
return
}
let task = URLSession.shared.dataTask(with: url) { data, _, _ in
DispatchQueue.main.async {
updateState(nil, { var s = state.downloadingNames; s.remove(name); return s }(), nil)
guard let data = data, !data.isEmpty else { return }
let sanitized = name.replacingOccurrences(of: " ", with: "_")
let file = dir.appendingPathComponent(sanitized + ".ttf")
do {
try data.write(to: file)
CTFontManagerRegisterFontURLs([file] as CFArray, .process, true, nil)
updateState(nil, nil, { var s = state.downloadedNames; s.insert(name); return s }())
onFontAdded()
} catch {}
}
}
task.resume()
}
)
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(presentationData.strings.baseLanguageCode == "ru" ? "Загрузить шрифт" : "Download font"),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let signal: Signal<(ItemListControllerState, (ItemListNodeState, FontDownloadArguments)), NoError> = statePromise.get()
|> map { (s: FontDownloadState) -> (ItemListControllerState, (ItemListNodeState, FontDownloadArguments)) in
var entries: [FontDownloadEntry] = []
entries.append(.search(entryId: 0, query: s.searchQuery))
let filtered = kDownloadableFonts.filter { s.searchQuery.isEmpty || $0.name.localizedCaseInsensitiveContains(s.searchQuery) }
for (idx, item) in filtered.enumerated() {
let id = idx + 1
let isDl = s.downloadingNames.contains(item.name)
let isDone = s.downloadedNames.contains(item.name) || isFontDownloaded(displayName: item.name)
entries.append(.font(entryId: id, name: item.name, url: item.url, isDownloading: isDl, isDownloaded: isDone))
}
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
return controller
}
@@ -0,0 +1,163 @@
// MARK: Swiftgram - Font replacement picker (A-Font style)
import SGSimpleSettings
import Foundation
import UIKit
import CoreText
import CoreGraphics
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import AccountContext
/// Bundled default fonts shown at the top of the picker (display name, bundle filename without extension).
private let bundledDefaultFonts: [(displayName: String, fileName: String)] = [
(displayName: "Minecraft Default Bold", fileName: "MinecraftDefault-Bold-2"),
]
/// Registers a .ttf from the given bundle if present and returns its PostScript name, or nil.
private func registerBundledFont(bundle: Bundle, fileName: String) -> String? {
guard let path = bundle.path(forResource: fileName, ofType: "ttf") else {
return nil
}
let url = URL(fileURLWithPath: path)
guard let provider = CGDataProvider(url: url as CFURL),
let cgFont = CGFont(provider),
let name = cgFont.postScriptName as String?, !name.isEmpty else {
return nil
}
CTFontManagerRegisterFontURLs([url] as CFArray, .process, true, nil)
return name
}
public enum FontReplacementPickerMode {
case main
case bold
}
private struct FontReplacementPickerArguments {
let selectFont: (String) -> Void
let dismiss: () -> Void
}
private struct FontReplacementPickerEntry: ItemListNodeEntry {
let entryId: Int
let fontName: String
let displayTitle: String
var section: ItemListSectionId { 0 }
var stableId: Int { entryId }
var id: Int { entryId }
static func == (lhs: FontReplacementPickerEntry, rhs: FontReplacementPickerEntry) -> Bool {
lhs.entryId == rhs.entryId && lhs.fontName == rhs.fontName
}
static func < (lhs: FontReplacementPickerEntry, rhs: FontReplacementPickerEntry) -> Bool {
lhs.entryId < rhs.entryId
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! FontReplacementPickerArguments
let fontSize = presentationData.fontSize.itemListBaseFontSize
let textColor = presentationData.theme.list.itemAccentColor
let attributedTitle: NSAttributedString?
if fontName.isEmpty {
attributedTitle = nil
} else if let font = UIFont(name: fontName, size: fontSize) {
attributedTitle = NSAttributedString(string: displayTitle, font: font, textColor: textColor)
} else {
attributedTitle = nil
}
return ItemListDisclosureItem(
presentationData: presentationData,
title: displayTitle,
attributedTitle: attributedTitle,
label: "",
sectionId: section,
style: .blocks,
disclosureStyle: .none,
action: {
args.selectFont(self.fontName)
args.dismiss()
}
)
}
}
public func FontReplacementPickerController(context: AccountContext, mode: FontReplacementPickerMode, onSave: @escaping () -> Void) -> ViewController {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
var dismissImpl: (() -> Void)?
// Re-register downloaded fonts so they appear in the list
registerAllDownloadedFonts()
let (fontNames, bundledDisplayNames): ([String], [String: String]) = {
var list: [String] = []
var bundledMap: [String: String] = [:]
for (displayName, fileName) in bundledDefaultFonts {
if let postScriptName = registerBundledFont(bundle: .main, fileName: fileName), !list.contains(postScriptName) {
list.append(postScriptName)
bundledMap[postScriptName] = displayName
}
}
for family in UIFont.familyNames.sorted() {
for name in UIFont.fontNames(forFamilyName: family).sorted() {
if !list.contains(name) {
list.append(name)
}
}
}
return (list.sorted(), bundledMap)
}()
let selectFont: (String) -> Void = { name in
switch mode {
case .main:
SGSimpleSettings.shared.fontReplacementName = name
SGSimpleSettings.shared.fontReplacementFilePath = "" // only "Import from file" sets path
case .bold:
SGSimpleSettings.shared.fontReplacementBoldName = name
SGSimpleSettings.shared.fontReplacementBoldFilePath = ""
}
onSave()
dismissImpl?()
}
let arguments = FontReplacementPickerArguments(
selectFont: selectFont,
dismiss: { dismissImpl?() }
)
var entries: [FontReplacementPickerEntry] = []
let systemTitle = presentationData.strings.baseLanguageCode == "ru" ? "Системный" : "System"
let autoTitle = presentationData.strings.baseLanguageCode == "ru" ? "Авто" : "Auto"
entries.append(FontReplacementPickerEntry(entryId: 0, fontName: "", displayTitle: mode == .main ? systemTitle : autoTitle))
for (idx, name) in fontNames.enumerated() {
let displayTitle = bundledDisplayNames[name] ?? name
entries.append(FontReplacementPickerEntry(entryId: idx + 1, fontName: name, displayTitle: displayTitle))
}
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(mode == .main ? (presentationData.strings.baseLanguageCode == "ru" ? "Шрифт" : "Font") : (presentationData.strings.baseLanguageCode == "ru" ? "Жирный шрифт" : "Bold font")),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
initialScrollToItem: nil
)
let signal: Signal<(ItemListControllerState, (ItemListNodeState, FontReplacementPickerArguments)), NoError> = .single((controllerState, (listState, arguments)))
let controller = ItemListController(context: context, state: signal)
dismissImpl = { [weak controller] in
controller?.dismiss()
}
return controller
}
+135
View File
@@ -0,0 +1,135 @@
// MARK: Swiftgram GLEGram settings footer
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import ItemListUI
public final class GLEGramFooterItem: ItemListControllerFooterItem {
let theme: PresentationTheme
let title: String
let linkTitle: String
let action: () -> Void
public init(theme: PresentationTheme, title: String, linkTitle: String, action: @escaping () -> Void) {
self.theme = theme
self.title = title
self.linkTitle = linkTitle
self.action = action
}
public func isEqual(to: ItemListControllerFooterItem) -> Bool {
if let item = to as? GLEGramFooterItem {
return self.theme === item.theme && self.title == item.title && self.linkTitle == item.linkTitle
}
return false
}
public func node(current: ItemListControllerFooterItemNode?) -> ItemListControllerFooterItemNode {
if let current = current as? GLEGramFooterItemNode {
current.item = self
return current
}
return GLEGramFooterItemNode(item: self)
}
}
final class GLEGramFooterItemNode: ItemListControllerFooterItemNode {
private let backgroundNode: ASDisplayNode
private let titleNode: ImmediateTextNode
private let linkNode: ImmediateTextNode
private var validLayout: ContainerViewLayout?
var item: GLEGramFooterItem {
didSet {
updateItem()
if let layout = validLayout {
_ = updateLayout(layout: layout, transition: .immediate)
}
}
}
init(item: GLEGramFooterItem) {
self.item = item
self.backgroundNode = ASDisplayNode()
self.backgroundNode.backgroundColor = item.theme.list.blocksBackgroundColor
self.titleNode = ImmediateTextNode()
self.titleNode.maximumNumberOfLines = 1
self.linkNode = ImmediateTextNode()
self.linkNode.maximumNumberOfLines = 1
super.init()
addSubnode(backgroundNode)
addSubnode(titleNode)
addSubnode(linkNode)
updateItem()
}
private func updateItem() {
backgroundNode.backgroundColor = item.theme.list.blocksBackgroundColor
titleNode.attributedText = NSAttributedString(
string: item.title,
font: Font.regular(15.0),
textColor: item.theme.list.freeTextColor
)
linkNode.attributedText = NSAttributedString(
string: item.linkTitle,
font: Font.medium(15.0),
textColor: item.theme.list.itemAccentColor
)
}
override func updateBackgroundAlpha(_ alpha: CGFloat, transition: ContainedViewLayoutTransition) {
transition.updateAlpha(node: backgroundNode, alpha: alpha)
}
override func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) -> CGFloat {
validLayout = layout
let inset: CGFloat = 16.0
let verticalInset: CGFloat = 20.0
let spacing: CGFloat = 4.0
let width = layout.size.width - layout.safeInsets.left - layout.safeInsets.right - inset * 2.0
let titleSize = titleNode.updateLayout(CGSize(width: width, height: .greatestFiniteMagnitude))
let linkSize = linkNode.updateLayout(CGSize(width: width, height: .greatestFiniteMagnitude))
let contentHeight = titleSize.height + spacing + linkSize.height
let panelHeight = contentHeight + verticalInset * 2.0
let panelFrame = CGRect(
x: 0,
y: 0,
width: layout.size.width,
height: panelHeight
)
transition.updateFrame(node: backgroundNode, frame: panelFrame)
transition.updateFrame(
node: titleNode,
frame: CGRect(
x: layout.safeInsets.left + inset,
y: verticalInset,
width: titleSize.width,
height: titleSize.height
)
)
transition.updateFrame(
node: linkNode,
frame: CGRect(
x: layout.safeInsets.left + inset,
y: verticalInset + titleSize.height + spacing,
width: linkSize.width,
height: linkSize.height
)
)
return panelHeight
}
override func didLoad() {
super.didLoad()
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
}
@objc private func handleTap() {
item.action()
}
}
+110
View File
@@ -0,0 +1,110 @@
// MARK: Swiftgram GLEGram settings header (icon + title + tagline)
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import ItemListUI
import AppBundle
public final class GLEGramHeaderItem: ItemListControllerHeaderItem {
let theme: PresentationTheme
let title: String
let subtitle: String
public init(theme: PresentationTheme, title: String, subtitle: String) {
self.theme = theme
self.title = title
self.subtitle = subtitle
}
public func isEqual(to: ItemListControllerHeaderItem) -> Bool {
if let item = to as? GLEGramHeaderItem {
return theme === item.theme && title == item.title && subtitle == item.subtitle
}
return false
}
public func node(current: ItemListControllerHeaderItemNode?) -> ItemListControllerHeaderItemNode {
if let current = current as? GLEGramHeaderItemNode {
current.item = self
return current
}
return GLEGramHeaderItemNode(item: self)
}
}
private let titleFont = Font.bold(22.0)
private let subtitleFont = Font.regular(14.0)
private let iconSize: CGFloat = 64.0
private let iconCornerRadius: CGFloat = 14.0
final class GLEGramHeaderItemNode: ItemListControllerHeaderItemNode {
private let backgroundNode: ASDisplayNode
private let iconNode: ASImageNode
private let titleNode: ImmediateTextNode
private let subtitleNode: ImmediateTextNode
private var validLayout: ContainerViewLayout?
var item: GLEGramHeaderItem {
didSet {
updateItem()
if let layout = validLayout {
_ = updateLayout(layout: layout, transition: .immediate)
}
}
}
init(item: GLEGramHeaderItem) {
self.item = item
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.iconNode = ASImageNode()
self.iconNode.contentMode = .scaleAspectFit
self.iconNode.cornerRadius = iconCornerRadius
self.iconNode.clipsToBounds = true
if let rawIcon = UIImage(bundleImageName: "GLEGramSettings") {
self.iconNode.image = rawIcon
}
self.titleNode = ImmediateTextNode()
self.titleNode.maximumNumberOfLines = 1
self.subtitleNode = ImmediateTextNode()
self.subtitleNode.maximumNumberOfLines = 2
super.init()
addSubnode(backgroundNode)
addSubnode(iconNode)
addSubnode(titleNode)
addSubnode(subtitleNode)
updateItem()
}
private func updateItem() {
backgroundNode.backgroundColor = item.theme.list.blocksBackgroundColor
titleNode.attributedText = NSAttributedString(string: item.title, font: titleFont, textColor: item.theme.list.itemPrimaryTextColor)
subtitleNode.attributedText = NSAttributedString(string: item.subtitle, font: subtitleFont, textColor: item.theme.list.itemSecondaryTextColor)
}
override func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) -> CGFloat {
validLayout = layout
let width = layout.size.width - 32.0
let spacing: CGFloat = 6.0
let iconTitleSpacing: CGFloat = 10.0
let bottomInset: CGFloat = 4.0
let desiredHeaderHeight: CGFloat = 200.0
let extraTopOffset: CGFloat = 36.0
let titleSize = titleNode.updateLayout(CGSize(width: width, height: .greatestFiniteMagnitude))
let subtitleSize = subtitleNode.updateLayout(CGSize(width: width, height: .greatestFiniteMagnitude))
let subtitleHeight = min(subtitleSize.height, 36.0)
let contentBlockHeight = iconSize + iconTitleSpacing + titleSize.height + spacing + subtitleHeight
let topInset = extraTopOffset + max(12.0, (desiredHeaderHeight - extraTopOffset - contentBlockHeight - bottomInset) / 2.0)
backgroundNode.frame = CGRect(origin: .zero, size: CGSize(width: layout.size.width, height: desiredHeaderHeight))
iconNode.frame = CGRect(x: floor((layout.size.width - iconSize) / 2.0), y: topInset, width: iconSize, height: iconSize)
let titleY = topInset + iconSize + iconTitleSpacing
titleNode.frame = CGRect(x: floor((layout.size.width - titleSize.width) / 2.0), y: titleY, width: titleSize.width, height: titleSize.height)
subtitleNode.frame = CGRect(x: floor((layout.size.width - subtitleSize.width) / 2.0), y: titleY + titleSize.height + spacing, width: subtitleSize.width, height: subtitleHeight)
return desiredHeaderHeight
}
}
@@ -0,0 +1,463 @@
import Foundation
import SwiftUI
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import AccountContext
import SGSupporters
import SGSwiftUI
import LegacyUI
private let innerShadowWidth: CGFloat = 15.0
private let accentColorHex: String = "C0B0D8"
private struct GLEGramBackgroundView: View {
var body: some View {
ZStack {
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color(hex: "1A0A33"), location: 0.0),
.init(color: Color(hex: "3D1B6E"), location: 0.35),
.init(color: Color(hex: "2F1A57"), location: 0.7),
.init(color: Color(hex: "1A0A33"), location: 1.0),
]),
startPoint: .top,
endPoint: .bottom
)
.edgesIgnoringSafeArea(.all)
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color(hex: "4F298F").opacity(0.5), location: 0.0),
.init(color: Color.clear, location: 0.25),
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.edgesIgnoringSafeArea(.all)
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color(hex: "604080").opacity(0.3), location: 0.0),
.init(color: Color.clear, location: 0.2),
]),
startPoint: .topTrailing,
endPoint: .bottomLeading
)
.edgesIgnoringSafeArea(.all)
.overlay(
RoundedRectangle(cornerRadius: 0)
.stroke(Color.clear, lineWidth: 0)
.background(
ZStack {
innerShadow(x: -2, y: -2, blur: 6, color: Color(hex: "785B9E").opacity(0.6))
innerShadow(x: 2, y: 2, blur: 6, color: Color(hex: "4F298F").opacity(0.4))
}
)
)
.edgesIgnoringSafeArea(.all)
}
}
func innerShadow(x: CGFloat, y: CGFloat, blur: CGFloat, color: Color) -> some View {
RoundedRectangle(cornerRadius: 0)
.stroke(color, lineWidth: innerShadowWidth)
.blur(radius: blur)
.offset(x: x, y: y)
.mask(RoundedRectangle(cornerRadius: 0).fill(LinearGradient(gradient: Gradient(colors: [Color.black, Color.clear]), startPoint: .top, endPoint: .bottom)))
}
}
@available(iOS 13.0, *)
private struct GLEGramPaywallView: View {
let promo: GLEGramPromo
let trialAvailable: Bool
let onTrial: () -> Void
let onSubscribe: () -> Void
let onBack: () -> Void
@Environment(\.containerViewLayout) var containerViewLayout: ContainerViewLayout?
@State private var buttonsSectionSize: CGSize = .zero
var body: some View {
ZStack {
GLEGramBackgroundView()
ZStack(alignment: .bottom) {
ScrollView(showsIndicators: false) {
VStack(spacing: 28) {
ZStack {
Circle()
.fill(
RadialGradient(
gradient: Gradient(colors: [
Color(hex: "4F298F").opacity(0.4),
Color.clear
]),
center: .center,
startRadius: 20,
endRadius: 60
)
)
.frame(width: 120, height: 120)
Image("GLEGramSettings")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 88, height: 88)
.shadow(color: Color(hex: "785B9E").opacity(0.5), radius: 12, x: 0, y: 4)
}
VStack(spacing: 10) {
Text(promo.title)
.font(.system(size: 28, weight: .bold))
.foregroundColor(.white)
.shadow(color: Color(hex: "1A0A33").opacity(0.5), radius: 2, x: 0, y: 1)
Text(promo.subtitle)
.font(.callout)
.foregroundColor(Color(hex: "D0C0E8"))
.multilineTextAlignment(.center)
.padding(.horizontal)
.lineSpacing(4)
}
VStack(alignment: .leading, spacing: 10) {
ForEach(promo.features, id: \.self) { feature in
HStack(alignment: .top, spacing: 14) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color(hex: accentColorHex))
.font(.system(size: 22))
Text(feature)
.font(.subheadline)
.foregroundColor(Color(hex: "E8E0F0"))
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color.white.opacity(0.08))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color(hex: "4F298F").opacity(0.3), lineWidth: 1)
)
)
}
}
.padding(.horizontal)
Color.clear.frame(height: buttonsSectionSize.height + 24)
}
.padding(.vertical, 36)
}
.padding(.leading, max(innerShadowWidth + 8.0, sgLeftSafeAreaInset(containerViewLayout)))
.padding(.trailing, max(innerShadowWidth + 8.0, sgRightSafeAreaInset(containerViewLayout)))
VStack(spacing: 0) {
Rectangle()
.fill(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "1A0A33").opacity(0),
Color(hex: "1A0A33").opacity(0.8)
]),
startPoint: .top,
endPoint: .bottom
)
)
.frame(height: 20)
Divider()
.background(Color(hex: "4F298F").opacity(0.4))
VStack(spacing: 12) {
if trialAvailable {
Button(action: onTrial) {
Text(promo.trialButtonText)
.fontWeight(.semibold)
.font(.system(size: 17))
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "A78BDA"),
Color(hex: "785B9E")
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.foregroundColor(.white)
.cornerRadius(14)
.shadow(color: Color(hex: "4F298F").opacity(0.5), radius: 8, x: 0, y: 4)
}
.buttonStyle(PlainButtonStyle())
}
Button(action: onSubscribe) {
Text(promo.subscribeButtonText)
.fontWeight(.semibold)
.font(.system(size: 17))
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color.white.opacity(0.12))
.cornerRadius(14)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color(hex: "C0B0D8").opacity(0.4), lineWidth: 1)
)
.foregroundColor(Color(hex: "E8E0F0"))
}
.buttonStyle(PlainButtonStyle())
}
.padding([.horizontal, .top], 16)
.padding(.bottom, sgBottomSafeAreaInset(containerViewLayout) + 16)
}
.background(Color(hex: "1A0A33"))
.shadow(color: Color(hex: "1A0A33").opacity(0.5), radius: 12, y: -4)
.trackSize($buttonsSectionSize)
}
}
.overlay(backButtonView)
.colorScheme(.dark)
}
private var backButtonView: some View {
VStack {
HStack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(Color(hex: "E8E0F0"))
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
Spacer()
}
.padding([.top, .leading], 16)
Spacer()
}
}
}
public func gleGramPaywallController(context: AccountContext, promo: GLEGramPromo, trialAvailable: Bool) -> ViewController {
if #available(iOS 13.0, *) {
let theme = defaultDarkColorPresentationTheme
let strings = context.sharedContext.currentPresentationData.with { $0 }.strings
let legacyController = LegacySwiftUIController(
presentation: .navigation,
theme: theme,
strings: strings
)
legacyController.statusBar.statusBarStyle = .White
legacyController.displayNavigationBar = false
legacyController.title = ""
var weakLegacy: LegacySwiftUIController?
weakLegacy = legacyController
let swiftUIView = SGSwiftUIView<GLEGramPaywallView>(
legacyController: legacyController,
content: {
GLEGramPaywallView(
promo: promo,
trialAvailable: trialAvailable,
onTrial: { [weak context] in
guard let context else { return }
let userId = context.account.peerId.id._internalGetInt64Value()
guard let signal = startTrialIfConfigured(userId: userId) else { return }
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
_ = (signal |> deliverOnMainQueue).start(next: { trial in
if let trial = trial, trial.alreadyUsed {
let text = lang == "ru" ? "Пробный период уже был использован" : "Trial has already been used"
weakLegacy?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})
]), in: .window(.root))
} else if let trial = trial, trial.active {
refreshGLEGramStatusIfConfigured(userId: userId)
let text = lang == "ru" ? "Пробный период активирован!" : "Trial activated!"
weakLegacy?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {
weakLegacy?.navigationController?.popViewController(animated: true)
})
]), in: .window(.root))
}
}, error: { err in
let text: String
if case .tooManyRequests = err {
text = lang == "ru" ? "Слишком много запросов. Подождите минуту." : "Too many requests. Wait a minute."
} else {
text = lang == "ru" ? "Ошибка сети. Попробуйте позже." : "Network error. Try again later."
}
weakLegacy?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})
]), in: .window(.root))
})
},
onSubscribe: { [weak context] in
guard let context, let urlString = promo.miniAppUrl, isUrlSafeForExternalOpen(urlString) else { return }
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: urlString, forceExternal: false, presentationData: presentationData, navigationController: weakLegacy?.navigationController as? NavigationController, dismissInput: {})
},
onBack: { weakLegacy?.navigationController?.popViewController(animated: true) }
)
}
)
let hostingController = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
legacyController.bind(controller: hostingController)
return legacyController
} else {
return GLEGramPaywallFallbackController(context: context, promo: promo, trialAvailable: trialAvailable)
}
}
private final class GLEGramPaywallFallbackController: ViewController {
private let context: AccountContext
private let promo: GLEGramPromo
private let trialAvailable: Bool
init(context: AccountContext, promo: GLEGramPromo, trialAvailable: Bool) {
self.context = context
self.promo = promo
self.trialAvailable = trialAvailable
super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: context.sharedContext.currentPresentationData.with { $0 }))
self.title = "GLEGram"
}
required init(coder: NSCoder) { fatalError() }
override public func loadDisplayNode() {
self.displayNode = ASDisplayNode()
self.displayNode.backgroundColor = UIColor(red: 26/255, green: 10/255, blue: 51/255, alpha: 1)
}
private var scrollView: UIScrollView?
private var contentLoaded = false
override public func viewDidLoad() {
super.viewDidLoad()
let sv = UIScrollView()
sv.alwaysBounceVertical = true
view.addSubview(sv)
scrollView = sv
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
guard let sv = scrollView else { return }
if !contentLoaded {
contentLoaded = true
let sideInset: CGFloat = 24
let maxW = layout.size.width - sideInset * 2
var y: CGFloat = 40
let titleLabel = UILabel()
titleLabel.text = promo.title
titleLabel.font = Font.bold(28)
titleLabel.textColor = .white
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 0
titleLabel.frame = CGRect(x: sideInset, y: y, width: maxW, height: 60)
sv.addSubview(titleLabel)
y += 70
let subLabel = UILabel()
subLabel.text = promo.subtitle
subLabel.font = Font.regular(16)
subLabel.textColor = UIColor(red: 232/255, green: 224/255, blue: 240/255, alpha: 1)
subLabel.textAlignment = .center
subLabel.numberOfLines = 0
let subSize = subLabel.sizeThatFits(CGSize(width: maxW, height: 200))
subLabel.frame = CGRect(x: sideInset, y: y, width: maxW, height: subSize.height)
sv.addSubview(subLabel)
y += subSize.height + 24
for f in promo.features {
let l = UILabel()
l.text = "\(f)"
l.font = Font.regular(17)
l.textColor = .white
l.numberOfLines = 0
let sz = l.sizeThatFits(CGSize(width: maxW - 16, height: 100))
l.frame = CGRect(x: sideInset + 8, y: y, width: maxW - 16, height: sz.height)
sv.addSubview(l)
y += sz.height + 12
}
y += 24
if trialAvailable {
let btn = UIButton(type: .system)
btn.setTitle(promo.trialButtonText, for: .normal)
btn.titleLabel?.font = Font.semibold(17)
btn.setTitleColor(.white, for: .normal)
btn.backgroundColor = UIColor(red: 120/255, green: 91/255, blue: 158/255, alpha: 1)
btn.layer.cornerRadius = 12
btn.frame = CGRect(x: sideInset, y: y, width: maxW, height: 50)
btn.addTarget(self, action: #selector(trialTap), for: .touchUpInside)
sv.addSubview(btn)
y += 62
}
let subBtn = UIButton(type: .system)
subBtn.setTitle(promo.subscribeButtonText, for: .normal)
subBtn.titleLabel?.font = Font.semibold(17)
subBtn.setTitleColor(.white, for: .normal)
subBtn.backgroundColor = UIColor(white: 1, alpha: 0.12)
subBtn.layer.cornerRadius = 12
subBtn.frame = CGRect(x: sideInset, y: y, width: maxW, height: 50)
subBtn.addTarget(self, action: #selector(subscribeTap), for: .touchUpInside)
sv.addSubview(subBtn)
y += 70
sv.contentSize = CGSize(width: layout.size.width, height: y)
}
let topInset = layout.safeInsets.top
sv.frame = CGRect(x: 0, y: topInset, width: layout.size.width, height: layout.size.height - topInset)
}
@objc private func trialTap() {
let userId = context.account.peerId.id._internalGetInt64Value()
guard let signal = startTrialIfConfigured(userId: userId) else { return }
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
_ = (signal |> deliverOnMainQueue).start(next: { [weak self] trial in
guard let self else { return }
if let trial = trial, trial.alreadyUsed {
let text = lang == "ru" ? "Пробный период уже был использован" : "Trial has already been used"
self.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})
]), in: .window(.root))
} else if let trial = trial, trial.active {
refreshGLEGramStatusIfConfigured(userId: userId)
let text = lang == "ru" ? "Пробный период активирован!" : "Trial activated!"
self.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: { [weak self] in
self?.navigationController?.popViewController(animated: true)
})
]), in: .window(.root))
}
}, error: { [weak self] err in
guard let self else { return }
let text: String
if case .tooManyRequests = err {
text = lang == "ru" ? "Слишком много запросов. Подождите минуту." : "Too many requests. Wait a minute."
} else {
text = lang == "ru" ? "Ошибка сети. Попробуйте позже." : "Network error. Try again later."
}
self.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: text, actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})
]), in: .window(.root))
})
}
@objc private func subscribeTap() {
guard let urlString = promo.miniAppUrl, isUrlSafeForExternalOpen(urlString) else { return }
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: urlString, forceExternal: false, presentationData: presentationData, navigationController: navigationController as? NavigationController, dismissInput: {})
}
}
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
// MARK: Swiftgram Plugin row item (like Active sites: icon, name, author, description, switch)
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import AppBundle
/// One row per plugin: icon, name, author, description; switch on the right (like Active sites).
final class ItemListPluginRowItem: ListViewItem, ItemListItem {
let presentationData: ItemListPresentationData
let plugin: PluginInfo
let icon: UIImage?
let sectionId: ItemListSectionId
let toggle: (Bool) -> Void
let action: (() -> Void)?
init(presentationData: ItemListPresentationData, plugin: PluginInfo, icon: UIImage?, sectionId: ItemListSectionId, toggle: @escaping (Bool) -> Void, action: (() -> Void)? = nil) {
self.presentationData = presentationData
self.plugin = plugin
self.icon = icon
self.sectionId = sectionId
self.toggle = toggle
self.action = action
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = ItemListPluginRowItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, { return (nil, { _ in apply(false) }) })
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? ItemListPluginRowItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
Queue.mainQueue().async {
completion(layout, { _ in apply(animation.isAnimated) })
}
}
}
}
}
var selectable: Bool { action != nil }
func selected(listView: ListView) {
listView.clearHighlightAnimated(true)
action?()
}
}
private let leftInsetNoIcon: CGFloat = 16.0
private let iconSize: CGFloat = 30.0
private let leftInsetWithIcon: CGFloat = 16.0 + iconSize + 13.0
private let switchWidth: CGFloat = 51.0
private let switchRightInset: CGFloat = 15.0
final class ItemListPluginRowItemNode: ListViewItemNode {
private let backgroundNode: ASDisplayNode
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private let maskNode: ASImageNode
private let iconNode: ASImageNode
private let titleNode: TextNode
private let authorNode: TextNode
private let descriptionNode: TextNode
private var switchNode: ASDisplayNode?
private var switchView: UISwitch?
private var layoutParams: (ItemListPluginRowItem, ListViewItemLayoutParams, ItemListNeighbors)?
init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.iconNode = ASImageNode()
self.iconNode.contentMode = .scaleAspectFit
self.iconNode.cornerRadius = 7.0
self.iconNode.clipsToBounds = true
self.iconNode.isLayerBacked = true
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.contentsScale = UIScreen.main.scale
self.authorNode = TextNode()
self.authorNode.isUserInteractionEnabled = false
self.authorNode.contentsScale = UIScreen.main.scale
self.descriptionNode = TextNode()
self.descriptionNode.isUserInteractionEnabled = false
self.descriptionNode.contentsScale = UIScreen.main.scale
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
super.init(layerBacked: false, rotated: false, seeThrough: false)
addSubnode(self.backgroundNode)
addSubnode(self.topStripeNode)
addSubnode(self.bottomStripeNode)
addSubnode(self.maskNode)
addSubnode(self.iconNode)
addSubnode(self.titleNode)
addSubnode(self.authorNode)
addSubnode(self.descriptionNode)
}
func asyncLayout() -> (ItemListPluginRowItem, ListViewItemLayoutParams, ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) {
let makeTitle = TextNode.asyncLayout(self.titleNode)
let makeAuthor = TextNode.asyncLayout(self.authorNode)
let makeDescription = TextNode.asyncLayout(self.descriptionNode)
return { item, params, neighbors in
let titleFont = Font.medium(floor(item.presentationData.fontSize.itemListBaseFontSize * 16.0 / 17.0))
let textFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 14.0 / 17.0))
let leftInset = leftInsetWithIcon + params.leftInset
let rightInset = params.rightInset + switchWidth + switchRightInset
let textWidth = params.width - leftInset - rightInset - 8.0
let meta = item.plugin.metadata
let titleAttr = NSAttributedString(string: meta.name, font: titleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor)
let lang = item.presentationData.strings.baseLanguageCode
let versionAuthor = (lang == "ru" ? "Версия " : "Version ") + "\(meta.version) · \(meta.author)"
let authorAttr = NSAttributedString(string: versionAuthor, font: textFont, textColor: item.presentationData.theme.list.itemSecondaryTextColor)
let descAttr = NSAttributedString(string: meta.description, font: textFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor)
let (titleLayout, titleApply) = makeTitle(TextNodeLayoutArguments(attributedString: titleAttr, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: .greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: .zero))
let (authorLayout, authorApply) = makeAuthor(TextNodeLayoutArguments(attributedString: authorAttr, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: .greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: .zero))
let (descLayout, descApply) = makeDescription(TextNodeLayoutArguments(attributedString: descAttr, backgroundColor: nil, maximumNumberOfLines: 2, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: .greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: .zero))
let verticalInset: CGFloat = 4.0
let rowHeight: CGFloat = verticalInset * 2 + 10 + titleLayout.size.height + 4 + authorLayout.size.height + 4 + descLayout.size.height
let contentHeight = max(75.0, rowHeight)
let insets = itemListNeighborsGroupedInsets(neighbors, params)
let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: contentHeight), insets: insets)
let layoutSize = layout.size
let separatorHeight = UIScreenPixel
return (layout, { [weak self] animated in
guard let self = self else { return }
self.layoutParams = (item, params, neighbors)
let theme = item.presentationData.theme
self.topStripeNode.backgroundColor = theme.list.itemBlocksSeparatorColor
self.bottomStripeNode.backgroundColor = theme.list.itemBlocksSeparatorColor
self.backgroundNode.backgroundColor = theme.list.itemBlocksBackgroundColor
self.highlightedBackgroundNode.backgroundColor = theme.list.itemHighlightedBackgroundColor
self.iconNode.image = item.icon
let _ = titleApply()
let _ = authorApply()
let _ = descApply()
if self.switchView == nil {
let sw = UISwitch()
sw.addTarget(self, action: #selector(self.switchChanged(_:)), for: .valueChanged)
self.switchView = sw
self.switchNode = ASDisplayNode(viewBlock: { sw })
self.addSubnode(self.switchNode!)
}
self.switchView?.isOn = item.plugin.enabled
self.switchView?.isUserInteractionEnabled = true
let hasCorners = itemListHasRoundedBlockLayout(params)
var hasTopCorners = false
var hasBottomCorners = false
switch neighbors.top {
case .sameSection(false): self.topStripeNode.isHidden = true
default: hasTopCorners = true; self.topStripeNode.isHidden = hasCorners
}
let bottomStripeInset: CGFloat
switch neighbors.bottom {
case .sameSection(false): bottomStripeInset = leftInsetWithIcon + params.leftInset
default: bottomStripeInset = 0; hasBottomCorners = true; self.bottomStripeNode.isHidden = hasCorners
}
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(theme, top: hasTopCorners, bottom: hasBottomCorners, glass: false) : nil
self.backgroundNode.frame = CGRect(origin: CGPoint(x: 0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentHeight + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
self.maskNode.frame = self.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0)
self.topStripeNode.frame = CGRect(x: 0, y: -min(insets.top, separatorHeight), width: layoutSize.width, height: separatorHeight)
self.bottomStripeNode.frame = CGRect(x: bottomStripeInset, y: contentHeight, width: layoutSize.width - bottomStripeInset - params.rightInset, height: separatorHeight)
self.iconNode.frame = CGRect(x: params.leftInset + 16, y: verticalInset + 10, width: iconSize, height: iconSize)
let textX = params.leftInset + 16 + iconSize + 13
self.titleNode.frame = CGRect(origin: CGPoint(x: textX, y: verticalInset + 10), size: titleLayout.size)
self.authorNode.frame = CGRect(origin: CGPoint(x: textX, y: verticalInset + 10 + titleLayout.size.height + 4), size: authorLayout.size)
self.descriptionNode.frame = CGRect(origin: CGPoint(x: textX, y: verticalInset + 10 + titleLayout.size.height + 4 + authorLayout.size.height + 4), size: descLayout.size)
let switchSize = self.switchView?.bounds.size ?? CGSize(width: switchWidth, height: 31)
self.switchNode?.frame = CGRect(x: params.width - params.rightInset - switchWidth - switchRightInset, y: floor((contentHeight - switchSize.height) / 2.0), width: switchWidth, height: switchSize.height)
self.highlightedBackgroundNode.frame = self.backgroundNode.frame
})
}
}
@objc private func switchChanged(_ sender: UISwitch) {
if let item = self.layoutParams?.0 {
item.toggle(sender.isOn)
}
}
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
super.setHighlighted(highlighted, at: point, animated: animated)
if highlighted {
self.highlightedBackgroundNode.alpha = 1
if self.highlightedBackgroundNode.supernode == nil {
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.backgroundNode)
}
} else {
if animated {
self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0, duration: 0.25)
}
self.highlightedBackgroundNode.alpha = 0
}
}
}
+37
View File
@@ -0,0 +1,37 @@
// MARK: Swiftgram Plugin bridge (Swift Python runtime for exteraGram .plugin files)
//
// This module provides a bridge to run or query exteraGram-style .plugin files (Python).
// - Default: metadata and settings detection via regex (PluginMetadataParser), works on iOS/macOS.
// - Optional: when PythonKit (https://github.com/pvieito/PythonKit) is available, use
// PythonPluginRuntime to execute plugin code in a sandbox and read metadata from Python.
//
// swift-bridge (https://github.com/chinedufn/swift-bridge) is for RustSwift; for SwiftPython
// we use PythonKit. This protocol allows swapping implementations (regex-only vs PythonKit).
import Foundation
/// Runtime used to parse or execute .plugin file content (exteraGram Python format).
public protocol PluginRuntime: Sendable {
/// Parses plugin metadata (__name__, __id__, __description__, etc.) from file content.
func parseMetadata(content: String) -> PluginMetadata?
/// Returns true if the plugin defines create_settings or __settings__ = True.
func hasCreateSettings(content: String) -> Bool
}
/// Default implementation using regex-based parsing (no Python required). Works on iOS and macOS.
public final class DefaultPluginRuntime: PluginRuntime, @unchecked Sendable {
public static let shared = DefaultPluginRuntime()
public init() {}
public func parseMetadata(content: String) -> PluginMetadata? {
PluginMetadataParser.parse(content: content)
}
public func hasCreateSettings(content: String) -> Bool {
PluginMetadataParser.hasCreateSettings(content: content)
}
}
/// Current runtime used by the app. Set to a PythonKit-based runtime when Python is available.
public var currentPluginRuntime: PluginRuntime = DefaultPluginRuntime.shared
+37
View File
@@ -0,0 +1,37 @@
// MARK: Swiftgram Plugin bridge via PythonKit (Swift Python)
//
// Uses PythonKit (https://github.com/pvieito/PythonKit) when available.
// exteraGram plugins import Android/Java (base_plugin, org.telegram.messenger, etc.);
// on iOS/macOS those are unavailable, so we use regex parsing by default. When PythonKit
// is linked, you can implement full execution with builtins.exec(code, globals, locals)
// and stub modules (base_plugin, java, ui, ...) so the script runs and exposes __name__, etc.
//
// To enable PythonKit: add as SPM dependency or vendored; on iOS embed a Python framework.
import Foundation
#if canImport(PythonKit)
import PythonKit
/// Runtime that can use Python to parse/run plugin content when PythonKit is available.
/// Currently delegates to regex parser; replace with exec()-based implementation when
/// stubs for base_plugin/java/android are ready.
public final class PythonPluginRuntime: PluginRuntime, @unchecked Sendable {
public static let shared = PythonPluginRuntime()
private init() {}
public func parseMetadata(content: String) -> PluginMetadata? {
// Optional: use Python builtins.exec(content, globals, locals) with stubbed
// base_plugin, java, ui, etc., then read __name__, __id__, ... from globals.
// For now use regex so it works without a full Python stub environment.
return PluginMetadataParser.parse(content: content)
}
public func hasCreateSettings(content: String) -> Bool {
PluginMetadataParser.hasCreateSettings(content: content)
}
}
#else
// When PythonKit is not linked, PythonPluginRuntime is not compiled; app uses DefaultPluginRuntime.
#endif
@@ -0,0 +1,241 @@
// MARK: GLEGram Plugin code editor (create/edit JS plugins inline)
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
// MARK: - State
private final class PluginCodeEditorStateHolder {
var name: String
var code: String
init(name: String, code: String) {
self.name = name
self.code = code
}
}
private struct PluginCodeEditorState: Equatable {
var name: String
var code: String
}
// MARK: - Entries
private enum PluginCodeEditorEntry: ItemListNodeEntry {
case nameInput(id: Int, text: String, placeholder: String)
case codeInput(id: Int, text: String, placeholder: String)
case notice(id: Int, text: String)
var section: ItemListSectionId {
switch self {
case .nameInput: return 0
case .codeInput: return 1
case .notice: return 2
}
}
var stableId: Int {
switch self {
case .nameInput(let id, _, _): return id
case .codeInput(let id, _, _): return id
case .notice(let id, _): return id
}
}
static func == (lhs: PluginCodeEditorEntry, rhs: PluginCodeEditorEntry) -> Bool {
switch (lhs, rhs) {
case let (.nameInput(a, t1, p1), .nameInput(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.codeInput(a, t1, p1), .codeInput(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.notice(a, t1), .notice(b, t2)): return a == b && t1 == t2
default: return false
}
}
static func < (lhs: PluginCodeEditorEntry, rhs: PluginCodeEditorEntry) -> Bool {
lhs.stableId < rhs.stableId
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! PluginCodeEditorArguments
switch self {
case .nameInput(_, let text, let placeholder):
return ItemListSingleLineInputItem(
presentationData: presentationData,
title: NSAttributedString(),
text: text,
placeholder: placeholder,
sectionId: section,
textUpdated: { newText in args.updatedName(newText) },
action: {}
)
case .codeInput(_, let text, let placeholder):
return ItemListMultilineInputItem(
presentationData: presentationData,
text: text,
placeholder: placeholder,
maxLength: nil,
sectionId: section,
style: .blocks,
textUpdated: { newText in args.updatedCode(newText) },
updatedFocus: nil,
tag: nil,
action: nil,
inlineAction: nil
)
case .notice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
}
}
}
// MARK: - Arguments
private final class PluginCodeEditorArguments {
var updatedName: (String) -> Void = { _ in }
var updatedCode: (String) -> Void = { _ in }
}
private final class PluginCodeEditorNavActions {
var cancel: (() -> Void)?
var done: (() -> Void)?
}
// MARK: - Entries builder
private func pluginCodeEditorEntries(state: PluginCodeEditorState, presentationData: PresentationData) -> [PluginCodeEditorEntry] {
let lang = presentationData.strings.baseLanguageCode
var entries: [PluginCodeEditorEntry] = []
entries.append(.nameInput(id: 0, text: state.name, placeholder: lang == "ru" ? "Имя плагина" : "Plugin name"))
entries.append(.codeInput(id: 1, text: state.code, placeholder: lang == "ru" ? "JavaScript код..." : "JavaScript code..."))
let noticeText = lang == "ru"
? "Используйте GLEGram.ui, GLEGram.chat, GLEGram.compose, GLEGram.messageActions, GLEGram.intercept, GLEGram.network, GLEGram.settings, GLEGram.events API."
: "Use GLEGram.ui, GLEGram.chat, GLEGram.compose, GLEGram.messageActions, GLEGram.intercept, GLEGram.network, GLEGram.settings, GLEGram.events API."
entries.append(.notice(id: 2, text: noticeText))
return entries
}
// MARK: - Controller
public func pluginCodeEditorController(context: AccountContext, existingPlugin: PluginInfo?, initialCode: String, onSave: @escaping (PluginInfo) -> Void) -> ViewController {
let initialName = existingPlugin?.metadata.name ?? ""
let stateHolder = PluginCodeEditorStateHolder(name: initialName, code: initialCode)
let navActions = PluginCodeEditorNavActions()
let statePromise = ValuePromise(PluginCodeEditorState(name: initialName, code: initialCode), ignoreRepeated: true)
let arguments = PluginCodeEditorArguments()
arguments.updatedName = { newName in
stateHolder.name = newName
statePromise.set(PluginCodeEditorState(name: newName, code: stateHolder.code))
}
arguments.updatedCode = { newCode in
stateHolder.code = newCode
statePromise.set(PluginCodeEditorState(name: stateHolder.name, code: newCode))
}
let signal = combineLatest(context.sharedContext.presentationData, statePromise.get())
|> map { presentationData, state -> (ItemListControllerState, (ItemListNodeState, PluginCodeEditorArguments)) in
let lang = presentationData.strings.baseLanguageCode
let title = existingPlugin != nil
? (lang == "ru" ? "Редактор" : "Editor")
: (lang == "ru" ? "Новый плагин" : "New Plugin")
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(title),
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Cancel), style: .regular, enabled: true, action: { navActions.cancel?() }),
rightNavigationButton: ItemListNavigationButton(content: .text(lang == "ru" ? "Сохранить" : "Save"), style: .bold, enabled: !state.code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, action: { navActions.done?() }),
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = pluginCodeEditorEntries(state: state, presentationData: presentationData)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
navActions.cancel = { [weak controller] in
controller?.dismiss()
}
navActions.done = { [weak controller] in
let code = stateHolder.code.trimmingCharacters(in: .whitespacesAndNewlines)
guard !code.isEmpty else { return }
// Parse metadata from code
var metadata: PluginMetadata
if let parsed = PluginMetadataParser.parseJavaScript(content: code) {
metadata = parsed
} else {
let name = stateHolder.name.trimmingCharacters(in: .whitespacesAndNewlines)
let safeName = name.isEmpty ? "Untitled Plugin" : name
let safeId = existingPlugin?.metadata.id ?? safeName.lowercased()
.replacingOccurrences(of: " ", with: "-")
.filter { $0.isLetter || $0.isNumber || $0 == "-" }
let id = safeId.isEmpty ? "plugin-\(UUID().uuidString.prefix(8))" : safeId
metadata = PluginMetadata(id: id, name: safeName, description: "", version: "1.0", author: "")
}
// If editing, keep the same ID
if let existing = existingPlugin {
metadata = PluginMetadata(
id: existing.metadata.id,
name: metadata.name,
description: metadata.description,
version: metadata.version,
author: metadata.author,
iconRef: metadata.iconRef,
minVersion: metadata.minVersion,
hasUserDisplay: metadata.hasUserDisplay,
permissions: metadata.permissions
)
}
// Write file
let fileManager = FileManager.default
guard let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
let pluginsDir = supportURL.appendingPathComponent("Plugins", isDirectory: true)
try? fileManager.createDirectory(at: pluginsDir, withIntermediateDirectories: true)
let destURL = pluginsDir.appendingPathComponent("\(metadata.id).js")
try? code.write(to: destURL, atomically: true, encoding: .utf8)
// Unload old version if editing
if existingPlugin != nil {
PluginRunner.shared.unload(pluginId: metadata.id)
}
// Update installed list
let pluginInfo = PluginInfo(metadata: metadata, path: destURL.path, enabled: true, hasSettings: false)
var plugins: [PluginInfo]
if let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let existing = try? JSONDecoder().decode([PluginInfo].self, from: data) {
plugins = existing
} else {
plugins = []
}
plugins.removeAll { $0.metadata.id == metadata.id }
plugins.append(pluginInfo)
if let data = try? JSONEncoder().encode(plugins),
let json = String(data: data, encoding: .utf8) {
SGSimpleSettings.shared.installedPluginsJson = json
SGSimpleSettings.shared.synchronizeShared()
}
// Reload plugins
PluginRunner.shared.ensureLoaded()
onSave(pluginInfo)
controller?.dismiss()
}
return controller
}
@@ -0,0 +1,358 @@
// MARK: Swiftgram Plugin install popup (tap .plugin file in chat)
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import AccountContext
import SGSimpleSettings
import AppBundle
private func loadInstalledPlugins() -> [PluginInfo] {
guard let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let list = try? JSONDecoder().decode([PluginInfo].self, from: data) else {
return []
}
return list
}
private func saveInstalledPlugins(_ plugins: [PluginInfo]) {
if let data = try? JSONEncoder().encode(plugins),
let json = String(data: data, encoding: .utf8) {
SGSimpleSettings.shared.installedPluginsJson = json
SGSimpleSettings.shared.synchronizeShared()
}
}
/// Modal popup when user taps a .plugin file in chat: shows plugin info and "Install" button.
public final class PluginInstallPopupController: ViewController {
private let context: AccountContext
private let message: Message
private let file: TelegramMediaFile
private var onInstalled: (() -> Void)?
private var loadDisposable: Disposable?
private var state: State = .loading {
didSet { applyState() }
}
private enum State {
case loading
case loaded(metadata: PluginMetadata, hasSettings: Bool, filePath: String)
case error(String)
}
private let contentNode: PluginInstallPopupContentNode
public init(context: AccountContext, message: Message, file: TelegramMediaFile, onInstalled: (() -> Void)? = nil) {
self.context = context
self.message = message
self.file = file
self.onInstalled = onInstalled
self.contentNode = PluginInstallPopupContentNode()
super.init(navigationBarPresentationData: nil)
self.blocksBackgroundWhenInOverlay = true
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
loadDisposable?.dispose()
}
override public func loadDisplayNode() {
self.displayNode = contentNode
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
contentNode.backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
contentNode.controller = self
contentNode.installAction = { [weak self] enableAfterInstall in
self?.performInstall(enableAfterInstall: enableAfterInstall)
}
contentNode.closeAction = { [weak self] in
self?.dismiss()
}
contentNode.shareAction = { [weak self] in
self?.sharePlugin()
}
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Close, style: .plain, target: self, action: #selector(closeTapped))
self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(shareTapped))
applyState()
startLoading()
}
@objc private func closeTapped() {
dismiss()
}
@objc private func shareTapped() {
sharePlugin()
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
}
private func startLoading() {
let postbox = context.account.postbox
let resource = file.resource
loadDisposable?.dispose()
loadDisposable = (postbox.mediaBox.resourceData(resource, option: .complete(waitUntilFetchStatus: true))
|> filter { $0.complete }
|> take(1)
|> deliverOnMainQueue
).start(next: { [weak self] data in
guard let self = self else { return }
guard let content = try? String(contentsOfFile: data.path, encoding: .utf8) else {
self.state = .error("Не удалось прочитать файл")
return
}
guard let metadata = currentPluginRuntime.parseMetadata(content: content) else {
self.state = .error("Неверный формат плагина")
return
}
let hasSettings = currentPluginRuntime.hasCreateSettings(content: content)
self.state = .loaded(metadata: metadata, hasSettings: hasSettings, filePath: data.path)
})
}
private func applyState() {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
switch state {
case .loading:
contentNode.setLoading(presentationData: presentationData)
case .loaded(let metadata, let hasSettings, _):
contentNode.setLoaded(presentationData: presentationData, metadata: metadata, hasSettings: hasSettings)
case .error(let message):
contentNode.setError(presentationData: presentationData, message: message, retry: { [weak self] in
self?.state = .loading
self?.startLoading()
})
}
}
private func performInstall(enableAfterInstall: Bool) {
guard case .loaded(let metadata, let hasSettings, let filePath) = state else { return }
let fileManager = FileManager.default
guard let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
let pluginsDir = supportURL.appendingPathComponent("Plugins", isDirectory: true)
let destPath = pluginsDir.appendingPathComponent("\(metadata.id).plugin").path
do {
try fileManager.createDirectory(at: pluginsDir, withIntermediateDirectories: true)
let destURL = URL(fileURLWithPath: destPath)
try? fileManager.removeItem(at: destURL)
try fileManager.copyItem(at: URL(fileURLWithPath: filePath), to: destURL)
} catch {
contentNode.showError("Не удалось установить: \(error.localizedDescription)")
return
}
var plugins = loadInstalledPlugins()
plugins.removeAll { $0.metadata.id == metadata.id }
plugins.append(PluginInfo(metadata: metadata, path: destPath, enabled: enableAfterInstall, hasSettings: hasSettings))
saveInstalledPlugins(plugins)
onInstalled?()
dismiss()
}
private func sharePlugin() {
guard case .loaded(_, _, let filePath) = state else { return }
let url = URL(fileURLWithPath: filePath)
let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil)
if let window = self.view.window, let root = window.rootViewController {
var top = root
while let presented = top.presentedViewController { top = presented }
if let popover = activityVC.popoverPresentationController {
popover.sourceView = view
popover.sourceRect = CGRect(x: view.bounds.midX, y: 60, width: 0, height: 0)
popover.permittedArrowDirections = .up
}
top.present(activityVC, animated: true)
}
}
}
// MARK: - Content node (icon, name, version, description, Install, checkbox)
private final class PluginInstallPopupContentNode: ViewControllerTracingNode {
weak var controller: PluginInstallPopupController?
var installAction: ((Bool) -> Void)?
var closeAction: (() -> Void)?
var shareAction: (() -> Void)?
var retryBlock: (() -> Void)?
private let scrollNode = ASScrollNode()
private let iconNode = ASImageNode()
private let nameNode = ImmediateTextNode()
private let versionNode = ImmediateTextNode()
private let descriptionNode = ImmediateTextNode()
private let installButton = ASButtonNode()
private let enableAfterContainer = ASDisplayNode()
private let enableAfterLabel = ImmediateTextNode()
private let loadingNode = ASDisplayNode()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let errorLabel = ImmediateTextNode()
private let retryButton = ASButtonNode()
private var enableAfterInstall: Bool = true
private var currentMetadata: PluginMetadata?
private var switchView: UISwitch?
override init() {
super.init()
addSubnode(scrollNode)
scrollNode.addSubnode(iconNode)
scrollNode.addSubnode(nameNode)
scrollNode.addSubnode(versionNode)
scrollNode.addSubnode(descriptionNode)
scrollNode.addSubnode(installButton)
scrollNode.addSubnode(enableAfterContainer)
scrollNode.addSubnode(enableAfterLabel)
addSubnode(loadingNode)
addSubnode(errorLabel)
addSubnode(retryButton)
iconNode.contentMode = .scaleAspectFit
installButton.addTarget(self, action: #selector(installTapped), forControlEvents: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), forControlEvents: .touchUpInside)
}
func setLoading(presentationData: PresentationData) {
backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
loadingNode.isHidden = false
loadingNode.view.addSubview(loadingIndicator)
loadingIndicator.startAnimating()
scrollNode.isHidden = true
errorLabel.isHidden = true
retryButton.isHidden = true
}
func setLoaded(presentationData: PresentationData, metadata: PluginMetadata, hasSettings: Bool) {
backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
currentMetadata = metadata
loadingNode.isHidden = true
loadingIndicator.stopAnimating()
errorLabel.isHidden = true
retryButton.isHidden = true
scrollNode.isHidden = false
let theme = presentationData.theme
let lang = presentationData.strings.baseLanguageCode
let isRu = lang == "ru"
iconNode.image = (metadata.iconRef.flatMap { UIImage(bundleImageName: $0) }) ?? UIImage(bundleImageName: "glePlugins/1")
nameNode.attributedText = NSAttributedString(string: metadata.name, font: Font.bold(22), textColor: theme.list.itemPrimaryTextColor)
nameNode.maximumNumberOfLines = 1
nameNode.truncationMode = .byTruncatingTail
let versionAuthor = (isRu ? "Версия " : "Version ") + "\(metadata.version)" + (metadata.author.isEmpty ? "" : "\(metadata.author)")
versionNode.attributedText = NSAttributedString(string: versionAuthor, font: Font.regular(15), textColor: theme.list.itemSecondaryTextColor)
versionNode.maximumNumberOfLines = 1
descriptionNode.attributedText = NSAttributedString(string: metadata.description.isEmpty ? (isRu ? "Нет описания." : "No description.") : metadata.description, font: Font.regular(15), textColor: theme.list.itemPrimaryTextColor)
descriptionNode.maximumNumberOfLines = 6
descriptionNode.truncationMode = .byTruncatingTail
installButton.setTitle(isRu ? "Установить" : "Install", with: Font.semibold(17), with: .white, for: .normal)
installButton.backgroundColor = theme.list.itemAccentColor
installButton.cornerRadius = 12
installButton.contentEdgeInsets = UIEdgeInsets(top: 14, left: 24, bottom: 14, right: 24)
enableAfterLabel.attributedText = NSAttributedString(string: isRu ? "Включить после установки" : "Enable after installation", font: Font.regular(16), textColor: theme.list.itemPrimaryTextColor)
enableAfterLabel.maximumNumberOfLines = 1
if switchView == nil {
let sw = UISwitch()
sw.isOn = enableAfterInstall
sw.addTarget(self, action: #selector(enableAfterChanged(_:)), for: .valueChanged)
enableAfterContainer.view.addSubview(sw)
switchView = sw
}
switchView?.isOn = enableAfterInstall
layoutContent()
}
@objc private func enableAfterChanged(_ sender: UISwitch) {
enableAfterInstall = sender.isOn
}
func setError(presentationData: PresentationData, message: String, retry: @escaping () -> Void) {
backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
retryBlock = retry
currentMetadata = nil
loadingNode.isHidden = true
scrollNode.isHidden = true
errorLabel.isHidden = false
retryButton.isHidden = false
errorLabel.attributedText = NSAttributedString(string: message, font: Font.regular(16), textColor: presentationData.theme.list.itemDestructiveColor)
let retryTitle = (presentationData.strings.baseLanguageCode == "ru" ? "Повторить" : "Retry")
retryButton.setTitle(retryTitle, with: Font.regular(17), with: presentationData.theme.list.itemAccentColor, for: .normal)
layoutContent()
}
func showError(_ message: String) {
errorLabel.attributedText = NSAttributedString(string: message, font: Font.regular(16), textColor: .red)
errorLabel.isHidden = false
errorLabel.frame = CGRect(x: 24, y: 120, width: bounds.width - 48, height: 60)
}
@objc private func installTapped() {
installAction?(enableAfterInstall)
}
@objc private func retryTapped() {
guard let retry = retryBlock else { return }
retry()
}
private func layoutContent() {
let b = bounds
let w = b.width > 0 ? b.width : 320
let pad: CGFloat = 24
loadingIndicator.center = CGPoint(x: b.midX, y: b.midY)
loadingNode.frame = b
errorLabel.frame = CGRect(x: pad, y: b.midY - 40, width: w - pad * 2, height: 60)
retryButton.frame = CGRect(x: pad, y: b.midY + 20, width: w - pad * 2, height: 44)
scrollNode.frame = b
let contentW = w - pad * 2
iconNode.frame = CGRect(x: pad, y: 20, width: 56, height: 56)
nameNode.frame = CGRect(x: pad, y: 86, width: contentW, height: 28)
versionNode.frame = CGRect(x: pad, y: 118, width: contentW, height: 22)
let descY: CGFloat = 150
let descMaxH: CGFloat = 80
if let att = descriptionNode.attributedText {
let descSize = att.boundingRect(with: CGSize(width: contentW, height: descMaxH), options: .usesLineFragmentOrigin, context: nil).size
descriptionNode.frame = CGRect(x: pad, y: descY, width: contentW, height: min(descMaxH, ceil(descSize.height)))
} else {
descriptionNode.frame = CGRect(x: pad, y: descY, width: contentW, height: 22)
}
let buttonY: CGFloat = 240
installButton.frame = CGRect(x: pad, y: buttonY, width: contentW, height: 50)
let rowY: CGFloat = 306
let switchW: CGFloat = 51
let switchH: CGFloat = 31
enableAfterLabel.frame = CGRect(x: pad, y: rowY, width: contentW - switchW - 12, height: 24)
enableAfterContainer.frame = CGRect(x: w - pad - switchW, y: rowY, width: switchW, height: switchH)
switchView?.frame = CGRect(origin: .zero, size: CGSize(width: switchW, height: switchH))
let contentHeight: CGFloat = 360
scrollNode.view.contentSize = CGSize(width: w, height: contentHeight)
}
override func layout() {
super.layout()
layoutContent()
}
}
+381
View File
@@ -0,0 +1,381 @@
// MARK: Swiftgram Plugin list (like Active sites: icon, name, author, description, switch; Settings below)
import Foundation
import UIKit
import ObjectiveC
import UniformTypeIdentifiers
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
import AppBundle
private var documentPickerDelegateKey: UInt8 = 0
private func loadInstalledPlugins() -> [PluginInfo] {
guard let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let list = try? JSONDecoder().decode([PluginInfo].self, from: data) else {
return []
}
return list
}
private func saveInstalledPlugins(_ plugins: [PluginInfo]) {
if let data = try? JSONEncoder().encode(plugins),
let json = String(data: data, encoding: .utf8) {
SGSimpleSettings.shared.installedPluginsJson = json
SGSimpleSettings.shared.synchronizeShared()
}
}
// Custom entries: .plugin plugins + .deb/.dylib tweaks.
private enum PluginListEntry: ItemListNodeEntry {
case addHeader(id: Int, text: String)
case addAction(id: Int, text: String)
case addNotice(id: Int, text: String)
case addDebAction(id: Int, text: String)
case addDebNotice(id: Int, text: String)
case listHeader(id: Int, text: String)
case pluginRow(id: Int, plugin: PluginInfo)
case pluginSettings(id: Int, pluginId: String, text: String)
case pluginDelete(id: Int, pluginId: String, text: String)
case emptyNotice(id: Int, text: String)
case tweaksChannelLink(id: Int, text: String, url: String)
case tweaksHeader(id: Int, text: String)
case installLiveContainer(id: Int, text: String)
case tweaksDylibHeader(id: Int, text: String)
case tweakRow(id: Int, filename: String)
case tweakDelete(id: Int, filename: String, text: String)
case tweaksEmptyNotice(id: Int, text: String)
var id: Int { stableId }
var section: ItemListSectionId {
switch self {
case .addHeader, .addAction, .addNotice, .addDebAction, .addDebNotice: return 0
case .listHeader, .pluginRow, .pluginSettings, .pluginDelete, .emptyNotice: return 1
case .tweaksChannelLink, .tweaksHeader, .installLiveContainer, .tweaksDylibHeader, .tweakRow, .tweakDelete, .tweaksEmptyNotice: return 2
}
}
var stableId: Int {
switch self {
case .addHeader(let id, _), .addAction(let id, _), .addNotice(let id, _), .addDebAction(let id, _), .addDebNotice(let id, _),
.listHeader(let id, _), .pluginRow(let id, _), .pluginSettings(let id, _, _), .pluginDelete(let id, _, _), .emptyNotice(let id, _),
.tweaksChannelLink(let id, _, _), .tweaksHeader(let id, _), .installLiveContainer(let id, _), .tweaksDylibHeader(let id, _), .tweakRow(let id, _), .tweakDelete(let id, _, _), .tweaksEmptyNotice(let id, _): return id
}
}
static func < (lhs: PluginListEntry, rhs: PluginListEntry) -> Bool { lhs.stableId < rhs.stableId }
static func == (lhs: PluginListEntry, rhs: PluginListEntry) -> Bool {
switch (lhs, rhs) {
case let (.addHeader(a, t1), .addHeader(b, t2)), let (.addNotice(a, t1), .addNotice(b, t2)), let (.emptyNotice(a, t1), .emptyNotice(b, t2)): return a == b && t1 == t2
case let (.addAction(a, t1), .addAction(b, t2)), let (.addDebAction(a, t1), .addDebAction(b, t2)), let (.addDebNotice(a, t1), .addDebNotice(b, t2)): return a == b && t1 == t2
case let (.listHeader(a, t1), .listHeader(b, t2)), let (.tweaksHeader(a, t1), .tweaksHeader(b, t2)), let (.tweaksDylibHeader(a, t1), .tweaksDylibHeader(b, t2)): return a == b && t1 == t2
case let (.tweaksChannelLink(a, t1, u1), .tweaksChannelLink(b, t2, u2)): return a == b && t1 == t2 && u1 == u2
case let (.installLiveContainer(a, t1), .installLiveContainer(b, t2)): return a == b && t1 == t2
case let (.pluginRow(a, p1), .pluginRow(b, p2)): return a == b && p1.metadata.id == p2.metadata.id && p1.enabled == p2.enabled
case let (.pluginSettings(a, id1, t1), .pluginSettings(b, id2, t2)), let (.pluginDelete(a, id1, t1), .pluginDelete(b, id2, t2)): return a == b && id1 == id2 && t1 == t2
case let (.tweakRow(a, f1), .tweakRow(b, f2)): return a == b && f1 == f2
case let (.tweakDelete(a, f1, t1), .tweakDelete(b, f2, t2)): return a == b && f1 == f2 && t1 == t2
case let (.tweaksEmptyNotice(a, t1), .tweaksEmptyNotice(b, t2)): return a == b && t1 == t2
default: return false
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! PluginListArguments
switch self {
case .addHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .addAction(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.addPlugin() })
case .addNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
case .addDebAction(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.addDeb() })
case .addDebNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
case .listHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .pluginRow(_, let plugin):
let icon = args.iconResolver(plugin.metadata.iconRef)
return ItemListPluginRowItem(presentationData: presentationData, plugin: plugin, icon: icon, sectionId: self.section, toggle: { value in args.toggle(plugin.metadata.id, value) }, action: nil)
case .pluginSettings(_, let pluginId, let text):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: "", sectionId: self.section, style: .blocks, action: { args.openSettings(pluginId) })
case .pluginDelete(_, let pluginId, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.deletePlugin(pluginId) })
case .emptyNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
case .tweaksChannelLink(_, let text, let url):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: "", sectionId: self.section, style: .blocks, action: { args.openTweaksChannel(url) })
case .tweaksHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .installLiveContainer(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.openLiveContainer() })
case .tweaksDylibHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .tweakRow(_, let filename):
return ItemListDisclosureItem(presentationData: presentationData, title: filename, label: "", sectionId: self.section, style: .blocks, action: nil)
case .tweakDelete(_, let filename, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.removeTweak(filename) })
case .tweaksEmptyNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
}
}
}
private final class PluginListArguments {
let toggle: (String, Bool) -> Void
let openSettings: (String) -> Void
let deletePlugin: (String) -> Void
let addPlugin: () -> Void
let addDeb: () -> Void
let openTweaksChannel: (String) -> Void
let openLiveContainer: () -> Void
let removeTweak: (String) -> Void
let iconResolver: (String?) -> UIImage?
init(toggle: @escaping (String, Bool) -> Void, openSettings: @escaping (String) -> Void, deletePlugin: @escaping (String) -> Void, addPlugin: @escaping () -> Void, addDeb: @escaping () -> Void, openTweaksChannel: @escaping (String) -> Void, openLiveContainer: @escaping () -> Void, removeTweak: @escaping (String) -> Void, iconResolver: @escaping (String?) -> UIImage?) {
self.toggle = toggle
self.openSettings = openSettings
self.deletePlugin = deletePlugin
self.addPlugin = addPlugin
self.addDeb = addDeb
self.openTweaksChannel = openTweaksChannel
self.openLiveContainer = openLiveContainer
self.removeTweak = removeTweak
self.iconResolver = iconResolver
}
}
private func pluginListEntries(presentationData: PresentationData, plugins: [PluginInfo], tweakFilenames: [String]) -> [PluginListEntry] {
let lang = presentationData.strings.baseLanguageCode
var entries: [PluginListEntry] = []
var id = 0
entries.append(.addHeader(id: id, text: lang == "ru" ? "ДОБАВИТЬ ПЛАГИН" : "ADD PLUGIN"))
id += 1
entries.append(.addAction(id: id, text: lang == "ru" ? "Выбрать файл .plugin" : "Select .plugin file"))
id += 1
entries.append(.addNotice(id: id, text: lang == "ru" ? "Файлы плагинов .plugin можно устанавливать здесь." : "Plugin .plugin files can be installed here."))
id += 1
entries.append(.addDebAction(id: id, text: lang == "ru" ? "Установить пакет .deb (твики)" : "Install .deb package (tweaks)"))
id += 1
entries.append(.addDebNotice(id: id, text: lang == "ru" ? "Пакеты .deb (Cydia/Sileo) — из них извлекаются .dylib и устанавливаются. Перезапустите приложение после установки." : ".deb packages (Cydia/Sileo): .dylib files are extracted and installed. Restart the app after installing."))
id += 1
entries.append(.listHeader(id: id, text: lang == "ru" ? "УСТАНОВЛЕННЫЕ ПЛАГИНЫ" : "INSTALLED PLUGINS"))
id += 1
for plugin in plugins {
let meta = plugin.metadata
entries.append(.pluginRow(id: id, plugin: plugin))
id += 1
if plugin.hasSettings {
entries.append(.pluginSettings(id: id, pluginId: meta.id, text: lang == "ru" ? "Настройки" : "Settings"))
id += 1
}
entries.append(.pluginDelete(id: id, pluginId: meta.id, text: lang == "ru" ? "Удалить" : "Remove"))
id += 1
}
if plugins.isEmpty {
entries.append(.emptyNotice(id: id, text: lang == "ru" ? "Нет установленных плагинов." : "No installed plugins."))
}
id += 1
entries.append(.tweaksChannelLink(id: id, text: lang == "ru" ? "Скачать твики (канал)" : "Download tweaks (channel)", url: "https://t.me/glegramiostweaks"))
id += 1
entries.append(.tweaksHeader(id: id, text: lang == "ru" ? "УСТАНОВИТЬ В" : "INSTALL IN"))
id += 1
entries.append(.installLiveContainer(id: id, text: lang == "ru" ? "Установить в LiveContainer" : "Install in LiveContainer"))
id += 1
entries.append(.tweaksDylibHeader(id: id, text: lang == "ru" ? "УСТАНОВЛЕННЫЕ ТВИКИ (.dylib)" : "INSTALLED TWEAKS (.dylib)"))
id += 1
for filename in tweakFilenames {
entries.append(.tweakRow(id: id, filename: filename))
id += 1
entries.append(.tweakDelete(id: id, filename: filename, text: lang == "ru" ? "Удалить" : "Remove"))
id += 1
}
if tweakFilenames.isEmpty {
entries.append(.tweaksEmptyNotice(id: id, text: lang == "ru" ? "Нет установленных твиков. Установите .deb." : "No installed tweaks. Install a .deb package."))
}
return entries
}
public func PluginListController(context: AccountContext, onPluginsChanged: @escaping () -> Void) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
var presentDocumentPicker: (() -> Void)?
var pushControllerImpl: ((ViewController) -> Void)?
var backAction: (() -> Void)?
var presentDebPicker: (() -> Void)?
var openLiveContainerImpl: (() -> Void)?
var showDebResultAlertImpl: ((String, String) -> Void)?
let arguments = PluginListArguments(
toggle: { pluginId, value in
var plugins = loadInstalledPlugins()
if let idx = plugins.firstIndex(where: { $0.metadata.id == pluginId }) {
plugins[idx].enabled = value
saveInstalledPlugins(plugins)
reloadPromise.set(true)
onPluginsChanged()
}
},
openSettings: { pluginId in
let plugins = loadInstalledPlugins()
guard let plugin = plugins.first(where: { $0.metadata.id == pluginId }) else { return }
let settingsController = PluginSettingsController(context: context, plugin: plugin, onSave: {
reloadPromise.set(true)
onPluginsChanged()
})
pushControllerImpl?(settingsController)
},
deletePlugin: { pluginId in
var plugins = loadInstalledPlugins()
plugins.removeAll { $0.metadata.id == pluginId }
saveInstalledPlugins(plugins)
reloadPromise.set(true)
onPluginsChanged()
},
addPlugin: { presentDocumentPicker?() },
addDeb: { presentDebPicker?() },
openTweaksChannel: { url in
if let u = URL(string: url) { UIApplication.shared.open(u) }
},
openLiveContainer: { openLiveContainerImpl?() },
removeTweak: { filename in
try? TweakLoader.removeTweak(filename: filename)
reloadPromise.set(true)
onPluginsChanged()
},
iconResolver: { iconRef in
guard let ref = iconRef, !ref.isEmpty else { return nil }
if let img = UIImage(bundleImageName: ref) { return img }
return UIImage(bundleImageName: "glePlugins/1")
}
)
let signal = combineLatest(reloadPromise.get(), context.sharedContext.presentationData)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, PluginListArguments)) in
let plugins = loadInstalledPlugins()
let tweakFilenames = TweakLoader.installedTweakFilenames()
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(presentationData.strings.baseLanguageCode == "ru" ? "Плагины" : "Plugins"),
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Back), style: .regular, enabled: true, action: { backAction?() }),
rightNavigationButton: ItemListNavigationButton(content: .text("+"), style: .bold, enabled: true, action: { presentDocumentPicker?() }),
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = pluginListEntries(presentationData: presentationData, plugins: plugins, tweakFilenames: tweakFilenames)
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: nil, initialScrollToItem: nil)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
backAction = { [weak controller] in controller?.dismiss() }
presentDocumentPicker = { [weak controller] in
guard let controller = controller else { return }
let picker: UIDocumentPickerViewController
if #available(iOS 14.0, *) {
let pluginType = UTType(filenameExtension: "plugin") ?? .plainText
picker = UIDocumentPickerViewController(forOpeningContentTypes: [pluginType], asCopy: true)
} else {
picker = UIDocumentPickerViewController(documentTypes: ["public.plain-text", "public.data"], in: .import)
}
let delegate = PluginDocumentPickerDelegate(
context: context,
onPick: { url in
_ = url.startAccessingSecurityScopedResource()
defer { url.stopAccessingSecurityScopedResource() }
guard let content = try? String(contentsOf: url, encoding: .utf8),
let metadata = currentPluginRuntime.parseMetadata(content: content) else { return }
let hasSettings = currentPluginRuntime.hasCreateSettings(content: content)
let fileManager = FileManager.default
guard let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
let pluginsDir = supportURL.appendingPathComponent("Plugins", isDirectory: true)
try? fileManager.createDirectory(at: pluginsDir, withIntermediateDirectories: true)
let destURL = pluginsDir.appendingPathComponent("\(metadata.id).plugin")
try? fileManager.removeItem(at: destURL)
try? fileManager.copyItem(at: url, to: destURL)
var plugins = loadInstalledPlugins()
plugins.append(PluginInfo(metadata: metadata, path: destURL.path, enabled: true, hasSettings: hasSettings))
saveInstalledPlugins(plugins)
reloadPromise.set(true)
onPluginsChanged()
}
)
picker.delegate = delegate
objc_setAssociatedObject(picker, &documentPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
presentDebPicker = { [weak controller] in
guard let controller = controller else { return }
let picker: UIDocumentPickerViewController
if #available(iOS 14.0, *) {
let debType = UTType(filenameExtension: "deb") ?? .data
picker = UIDocumentPickerViewController(forOpeningContentTypes: [debType], asCopy: true)
} else {
picker = UIDocumentPickerViewController(documentTypes: ["public.data"], in: .import)
}
let delegate = PluginDocumentPickerDelegate(context: context, onPick: { url in
_ = url.startAccessingSecurityScopedResource()
defer { url.stopAccessingSecurityScopedResource() }
let lang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
do {
let tweaksDir = TweakLoader.ensureTweaksDirectory()
let result = try DebExtractor.installDeb(from: url, tweaksDirectory: tweaksDir)
let names = result.installedDylibs.joined(separator: ", ")
let pkg = result.packageName ?? "Tweak"
let ver = result.packageVersion.map { " \($0)" } ?? ""
showDebResultAlertImpl?(lang == "ru" ? "Установлено" : "Installed", "\(pkg)\(ver): \(names)\n\n" + (lang == "ru" ? "Перезапустите приложение." : "Restart the app."))
reloadPromise.set(true)
onPluginsChanged()
} catch {
showDebResultAlertImpl?(lang == "ru" ? "Ошибка" : "Error", error.localizedDescription)
}
})
picker.delegate = delegate
objc_setAssociatedObject(picker, &documentPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
pushControllerImpl = { [weak controller] vc in controller?.push(vc) }
let showNoAppAlert: () -> Void = { [weak controller] in
guard let ctrl = controller, let window = ctrl.view.window, let root = window.rootViewController else { return }
let lang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
let msg = lang == "ru" ? "Нет нужного приложения" : "No required app"
let alert = UIAlertController(title: nil, message: msg, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
var top = root
while let presented = top.presentedViewController { top = presented }
top.present(alert, animated: true)
}
openLiveContainerImpl = {
guard let url = URL(string: "livecontainer://") else { return }
UIApplication.shared.open(url, options: [:]) { opened in if !opened { showNoAppAlert() } }
}
showDebResultAlertImpl = { [weak controller] title, message in
guard let controller = controller, let window = controller.view.window, let root = window.rootViewController else { return }
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
var top = root
while let presented = top.presentedViewController { top = presented }
top.present(alert, animated: true)
}
return controller
}
private final class PluginDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate {
let context: AccountContext
let onPick: (URL) -> Void
init(context: AccountContext, onPick: @escaping (URL) -> Void) {
self.context = context
self.onPick = onPick
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
onPick(url)
}
}
+98
View File
@@ -0,0 +1,98 @@
// MARK: Swiftgram Plugin metadata (exteraGram-compatible .plugin file format)
import Foundation
/// Metadata parsed from a .plugin file (exteraGram plugin format).
public struct PluginMetadata: Codable, Equatable {
public let id: String
public let name: String
public let description: String
public let version: String
public let author: String
/// Icon reference e.g. "ApplicationEmoji/141" or "glePlugins/1".
public let iconRef: String?
public let minVersion: String?
/// If true, plugin modifies profile display (Fake Profilestyle). App reads settings and applies via userDisplayRunner.
public let hasUserDisplay: Bool
public init(id: String, name: String, description: String, version: String, author: String, iconRef: String? = nil, minVersion: String? = nil, hasUserDisplay: Bool = false) {
self.id = id
self.name = name
self.description = description
self.version = version
self.author = author
self.iconRef = iconRef
self.minVersion = minVersion
self.hasUserDisplay = hasUserDisplay
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
description = try c.decode(String.self, forKey: .description)
version = try c.decode(String.self, forKey: .version)
author = try c.decode(String.self, forKey: .author)
iconRef = try c.decodeIfPresent(String.self, forKey: .iconRef)
minVersion = try c.decodeIfPresent(String.self, forKey: .minVersion)
hasUserDisplay = try c.decodeIfPresent(Bool.self, forKey: .hasUserDisplay) ?? false
}
}
/// Installed plugin info (stored in settings).
public struct PluginInfo: Codable, Equatable {
public var metadata: PluginMetadata
public var path: String
public var enabled: Bool
public var hasSettings: Bool
public init(metadata: PluginMetadata, path: String, enabled: Bool, hasSettings: Bool) {
self.metadata = metadata
self.path = path
self.enabled = enabled
self.hasSettings = hasSettings
}
}
/// Parses exteraGram-style metadata from .plugin file content (Python script with __name__, __description__, etc.).
public enum PluginMetadataParser {
private static let namePattern = #"__name__\s*=\s*["']([^"']+)["']"#
private static let descriptionPattern = #"__description__\s*=\s*["']([^"']+)["']"#
private static let versionPattern = #"__version__\s*=\s*["']([^"']+)["']"#
private static let authorPattern = #"__author__\s*=\s*["']([^"']+)["']"#
private static let idPattern = #"__id__\s*=\s*["']([^"']+)["']"#
private static let iconPattern = #"__icon__\s*=\s*["']([^"']+)["']"#
private static let minVersionPattern = #"__min_version__\s*=\s*["']([^"']+)["']"#
private static let createSettingsPattern = #"def\s+create_settings\s*\("#
/// Some plugins set __settings__ = True (e.g. panic_passcode_pro).
private static let settingsFlagPattern = #"__settings__\s*=\s*True"#
/// Plugins that modify profile display (Fake Profilestyle) set __user_display__ = True.
private static let userDisplayPattern = #"__user_display__\s*=\s*True"#
public static func parse(content: String) -> PluginMetadata? {
guard let name = firstMatch(in: content, pattern: namePattern),
let id = firstMatch(in: content, pattern: idPattern) else {
return nil
}
let description = firstMatch(in: content, pattern: descriptionPattern) ?? ""
let version = firstMatch(in: content, pattern: versionPattern) ?? "1.0"
let author = firstMatch(in: content, pattern: authorPattern) ?? ""
let iconRef = firstMatch(in: content, pattern: iconPattern)
let minVersion = firstMatch(in: content, pattern: minVersionPattern)
let hasUserDisplay = content.range(of: userDisplayPattern, options: .regularExpression) != nil
return PluginMetadata(id: id, name: name, description: description, version: version, author: author, iconRef: iconRef, minVersion: minVersion, hasUserDisplay: hasUserDisplay)
}
public static func hasCreateSettings(content: String) -> Bool {
content.range(of: createSettingsPattern, options: .regularExpression) != nil
|| content.range(of: settingsFlagPattern, options: .regularExpression) != nil
}
private static func firstMatch(in string: String, pattern: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: string, range: NSRange(string.startIndex..., in: string)),
let range = Range(match.range(at: 1), in: string) else {
return nil
}
return String(string[range])
}
}
+299
View File
@@ -0,0 +1,299 @@
// MARK: Swiftgram Plugin runner (PythonKit-based execution of .plugin files)
//
// Sets SGPluginHooks.messageHookRunner so that outgoing messages are passed to Python plugins.
// Requires PythonKit (https://github.com/pvieito/PythonKit). Add via SPM or embed for macOS/simulator.
// On iOS device, embed a Python framework (e.g. BeeWare) for full support.
import Foundation
import SGSimpleSettings
#if canImport(PythonKit)
import PythonKit
#endif
private let basePluginSource = """
from enum import Enum
from typing import Any, Optional
class HookStrategy(str, Enum):
PASS = "PASS"
MODIFY = "MODIFY"
CANCEL = "CANCEL"
class HookResult:
def __init__(self, strategy=None, params=None):
self.strategy = strategy if strategy is not None else HookStrategy.PASS
self.params = params
class BasePlugin:
def __init__(self):
self._hooks = set()
def on_plugin_load(self):
pass
def add_on_send_message_hook(self):
self._hooks.add("on_send_message_hook")
def add_hook(self, name):
self._hooks.add(name)
def on_update_hook(self, update_name, account, update):
pass
def get_setting(self, key, default=None):
try:
return _get_setting(key, default)
except NameError:
return default
def set_setting(self, key, value):
try:
_set_setting(key, value)
except NameError:
pass
def _has_hook(self, name):
return name in self._hooks
"""
/// Call once at app startup to install the Python-based message hook runner (when PythonKit is available).
public enum PluginRunner {
private static var incomingMessageObserver: NSObjectProtocol?
public static func install() {
#if canImport(PythonKit)
SGPluginHooks.messageHookRunner = { accountPeerId, peerId, text, replyToMessageId, replyMessageInfo in
runPluginsSendMessageHook(accountPeerId: accountPeerId, peerId: peerId, text: text, replyToMessageId: replyToMessageId, replyMessageInfo: replyMessageInfo)
}
SGPluginHooks.incomingMessageHookRunner = { accountId, peerId, messageId, text, outgoing in
runPluginsIncomingMessageHook(accountId: accountId, peerId: peerId, messageId: messageId, text: text, outgoing: outgoing)
}
#else
SGPluginHooks.messageHookRunner = nil
SGPluginHooks.incomingMessageHookRunner = nil
#endif
SGPluginHooks.userDisplayRunner = applyUserDisplayFromPlugins
PluginRunner.incomingMessageObserver = NotificationCenter.default.addObserver(forName: SGPluginIncomingMessageNotificationName, object: nil, queue: .main) { note in
guard let u = note.userInfo,
let accountId = u["accountId"] as? Int64,
let peerId = u["peerId"] as? Int64,
let messageId = u["messageId"] as? Int64,
let outgoing = u["outgoing"] as? Bool else { return }
let text = u["text"] as? String
SGPluginHooks.incomingMessageHookRunner?(accountId, peerId, messageId, text, outgoing)
}
}
}
// MARK: - User display plugins (__user_display__ = True) generic runner, no app code changes per plugin
private func applyUserDisplayFromPlugins(accountId: Int64, user: PluginDisplayUser) -> PluginDisplayUser? {
guard SGSimpleSettings.shared.pluginSystemEnabled,
let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let plugins = try? JSONDecoder().decode([PluginInfo].self, from: data) else {
return nil
}
let host = PluginHost.shared
for plugin in plugins where plugin.enabled && plugin.metadata.hasUserDisplay {
let pluginId = plugin.metadata.id
guard host.getPluginSettingBool(pluginId: pluginId, key: "enabled", default: false) else { continue }
let targetIdStr = host.getPluginSetting(pluginId: pluginId, key: "target_user_id")?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let targetUserId: Int64
if targetIdStr.isEmpty {
targetUserId = accountId
} else if let parsed = Int64(targetIdStr) {
targetUserId = parsed
} else {
continue
}
if user.id != targetUserId { continue }
func s(_ key: String) -> String? {
host.getPluginSetting(pluginId: pluginId, key: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
}
let firstName = s("fake_first_name").flatMap { $0.isEmpty ? nil : $0 } ?? user.firstName
let lastName = s("fake_last_name").flatMap { $0.isEmpty ? nil : $0 } ?? user.lastName
let username = s("fake_username").flatMap { $0.isEmpty ? nil : $0 } ?? user.username
let phone = s("fake_phone").flatMap { $0.isEmpty ? nil : $0 } ?? user.phone
let id: Int64 = s("fake_id").flatMap { $0.isEmpty ? nil : Int64($0) } ?? user.id
let isPremium = host.getPluginSettingBool(pluginId: pluginId, key: "fake_premium", default: user.isPremium)
let isVerified = host.getPluginSettingBool(pluginId: pluginId, key: "fake_verified", default: user.isVerified)
let isScam = host.getPluginSettingBool(pluginId: pluginId, key: "fake_scam", default: user.isScam)
let isFake = host.getPluginSettingBool(pluginId: pluginId, key: "fake_fake", default: user.isFake)
let isSupport = host.getPluginSettingBool(pluginId: pluginId, key: "fake_support", default: user.isSupport)
let isBot = host.getPluginSettingBool(pluginId: pluginId, key: "fake_bot", default: user.isBot)
return PluginDisplayUser(
firstName: firstName,
lastName: lastName,
username: username,
phone: phone,
id: id,
isPremium: isPremium,
isVerified: isVerified,
isScam: isScam,
isFake: isFake,
isSupport: isSupport,
isBot: isBot
)
}
return nil
}
#if canImport(PythonKit)
private func runPluginsSendMessageHook(accountPeerId: Int64, peerId: Int64, text: String, replyToMessageId: Int64?, replyMessageInfo: ReplyMessageInfo?) -> SGPluginHookResult? {
guard let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let plugins = try? JSONDecoder().decode([PluginInfo].self, from: data) else {
return nil
}
let enabled = plugins.filter { $0.enabled }
guard !enabled.isEmpty else { return nil }
let replyId = Int(replyToMessageId ?? 0)
let accountId = Int(accountPeerId)
let peerIdInt = Int(peerId)
for pluginInfo in enabled {
let pluginId = pluginInfo.metadata.id
guard let content = try? String(contentsOfFile: pluginInfo.path, encoding: .utf8) else { continue }
let builtins = Python.import("builtins")
let globals = Python.dict()
do {
try builtins.exec.thunk.call(PythonObject(basePluginSource), globals, globals)
try builtins.exec.thunk.call(PythonObject(content), globals, globals)
} catch {
continue
}
guard let bp = globals["BasePlugin"], bp.isNone == false else { continue }
let findClassCode = """
_plugin_cls = None
for _n, _o in list(globals().items()):
if _n != 'BasePlugin' and isinstance(_o, type) and issubclass(_o, BasePlugin):
_plugin_cls = _o
break
"""
try? builtins.exec.thunk.call(PythonObject(findClassCode), globals, globals)
guard let cls = globals["_plugin_cls"], cls.isNone == false else { continue }
let instance: PythonObject
do {
instance = try cls.call()
} catch {
continue
}
_ = try? instance.on_plugin_load.call()
let hasHook = instance._has_hook.call("on_send_message_hook")
guard hasHook.bool == true else { continue }
// Build params object in Python (message, peer, replyToMsg; reply message document info for FileViewer-style plugins)
globals["_msg_text"] = PythonObject(text)
globals["_msg_peer"] = PythonObject(peerIdInt)
globals["_msg_reply"] = PythonObject(replyId)
globals["_msg_reply_id"] = PythonObject(Int(replyMessageInfo?.messageId ?? 0))
globals["_msg_reply_is_doc"] = PythonObject(replyMessageInfo?.isDocument ?? false)
globals["_msg_reply_file_path"] = replyMessageInfo?.filePath.map { PythonObject($0) } ?? Python.None
globals["_msg_reply_file_name"] = replyMessageInfo?.fileName.map { PythonObject($0) } ?? Python.None
globals["_msg_reply_mime"] = replyMessageInfo?.mimeType.map { PythonObject($0) } ?? Python.None
let paramsCode = """
class _Params:
pass
class _ReplyMsg:
pass
_params_obj = _Params()
_params_obj.message = _msg_text
_params_obj.peer = _msg_peer
_params_obj.replyToMsgId = _msg_reply
_params_obj.replyToMsg = _ReplyMsg()
_params_obj.replyToMsg.id = _msg_reply_id
_params_obj.replyToMsg.messageOwner = _ReplyMsg()
_params_obj.replyToMsg.messageOwner.id = _msg_reply_id
_params_obj.replyToMsg.isDocument = _msg_reply_is_doc
_params_obj.replyToMsg.filePath = _msg_reply_file_path
_params_obj.replyToMsg.fileName = _msg_reply_file_name
_params_obj.replyToMsg.mimeType = _msg_reply_mime
"""
try? builtins.exec.thunk.call(PythonObject(paramsCode), globals, globals)
guard let paramsObj = globals["_params_obj"], paramsObj.isNone == false else { continue }
let result: PythonObject
do {
result = try instance.on_send_message_hook.call(accountId, paramsObj)
} catch {
continue
}
guard let strategyObj = result.strategy, strategyObj.isNone == false else { continue }
let strategyStr = String(strategyObj) ?? "PASS"
if strategyStr == "CANCEL" {
return SGPluginHookResult(strategy: .cancel, message: nil)
}
if strategyStr == "MODIFY" {
var newMessage = text
if let p = result.params, p.isNone == false, let msg = p.message, msg.isNone == false {
newMessage = String(msg) ?? text
}
return SGPluginHookResult(strategy: .modify, message: newMessage)
}
}
return nil
}
private func runPluginsIncomingMessageHook(accountId: Int64, peerId: Int64, messageId: Int64, text: String?, outgoing: Bool) {
guard SGSimpleSettings.shared.pluginSystemEnabled,
let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let plugins = try? JSONDecoder().decode([PluginInfo].self, from: data) else { return }
let enabled = plugins.filter { $0.enabled }
guard !enabled.isEmpty else { return }
let accountIdInt = Int(accountId)
let peerIdInt = Int(peerId)
let messageIdInt = Int(messageId)
let textPy = text.map { PythonObject($0) } ?? Python.None
let outgoingPy = PythonObject(outgoing)
for pluginInfo in enabled {
guard let content = try? String(contentsOfFile: pluginInfo.path, encoding: .utf8) else { continue }
let builtins = Python.import("builtins")
let globals = Python.dict()
do {
try builtins.exec.thunk.call(PythonObject(basePluginSource), globals, globals)
try builtins.exec.thunk.call(PythonObject(content), globals, globals)
} catch { continue }
guard let bp = globals["BasePlugin"], bp.isNone == false else { continue }
let findClassCode = """
_plugin_cls = None
for _n, _o in list(globals().items()):
if _n != 'BasePlugin' and isinstance(_o, type) and issubclass(_o, BasePlugin):
_plugin_cls = _o
break
"""
try? builtins.exec.thunk.call(PythonObject(findClassCode), globals, globals)
guard let cls = globals["_plugin_cls"], cls.isNone == false else { continue }
let instance: PythonObject
do { instance = try cls.call() } catch { continue }
_ = try? instance.on_plugin_load.call()
let hasNewMessage = instance._has_hook.call("updateNewMessage").bool == true
let hasChannelMessage = instance._has_hook.call("updateNewChannelMessage").bool == true
guard hasNewMessage || hasChannelMessage else { continue }
let updateName = hasChannelMessage ? "updateNewChannelMessage" : "updateNewMessage"
globals["_upd_message"] = textPy
globals["_upd_peer"] = PythonObject(peerIdInt)
globals["_upd_msg_id"] = PythonObject(messageIdInt)
globals["_upd_outgoing"] = outgoingPy
let paramsCode = """
class _UpdateObj:
pass
_update_obj = _UpdateObj()
_update_obj.message = _upd_message
_update_obj.peer = _upd_peer
_update_obj.message_id = _upd_msg_id
_update_obj.outgoing = _upd_outgoing
"""
try? builtins.exec.thunk.call(PythonObject(paramsCode), globals, globals)
guard let updateObj = globals["_update_obj"], updateObj.isNone == false else { continue }
_ = try? instance.on_update_hook.call(updateName, accountIdInt, updateObj)
}
}
#endif
@@ -0,0 +1,188 @@
// MARK: Swiftgram Plugin settings screen
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
import SGItemListUI
private func loadInstalledPlugins() -> [PluginInfo] {
guard let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let list = try? JSONDecoder().decode([PluginInfo].self, from: data) else {
return []
}
return list
}
private func saveInstalledPlugins(_ plugins: [PluginInfo]) {
if let data = try? JSONEncoder().encode(plugins),
let json = String(data: data, encoding: .utf8) {
SGSimpleSettings.shared.installedPluginsJson = json
SGSimpleSettings.shared.synchronizeShared()
}
}
private enum PluginSettingsSection: Int32, SGItemListSection {
case main
case pluginOptions
case info
}
private typealias PluginSettingsEntry = SGItemListUIEntry<PluginSettingsSection, SGBoolSetting, AnyHashable, AnyHashable, AnyHashable, AnyHashable>
private let userDisplayBoolKeys: [(key: String, titleRu: String, titleEn: String)] = [
("enabled", "Включить подмену профиля", "Enable profile override"),
("fake_premium", "Premium статус", "Premium status"),
("fake_verified", "Статус верификации", "Verified status"),
("fake_scam", "Scam статус", "Scam status"),
("fake_fake", "Fake статус", "Fake status"),
("fake_support", "Support статус", "Support status"),
("fake_bot", "Bot статус", "Bot status"),
]
private let userDisplayStringKeys: [(key: String, titleRu: String, titleEn: String)] = [
("target_user_id", "Telegram ID пользователя", "User Telegram ID"),
("fake_first_name", "Имя", "First name"),
("fake_last_name", "Фамилия", "Last name"),
("fake_username", "Юзернейм (без @)", "Username (no @)"),
("fake_phone", "Номер телефона", "Phone number"),
("fake_id", "Telegram ID (визуально)", "Telegram ID (display)"),
]
private func pluginSettingsEntries(presentationData: PresentationData, plugin: PluginInfo) -> [PluginSettingsEntry] {
let lang = presentationData.strings.baseLanguageCode
let isRu = lang == "ru"
var entries: [PluginSettingsEntry] = []
let id = SGItemListCounter()
let host = PluginHost.shared
let pluginId = plugin.metadata.id
entries.append(.header(id: id.count, section: .main, text: isRu ? "ПЛАГИН" : "PLUGIN", badge: nil))
let enableText = plugin.enabled ? (isRu ? "Выключить плагин" : "Disable plugin") : (isRu ? "Включить плагин" : "Enable plugin")
entries.append(.action(id: id.count, section: .main, actionType: "toggleEnabled" as AnyHashable, text: enableText, kind: .generic))
entries.append(.notice(id: id.count, section: .main, text: isRu ? "Включает функциональность плагина." : "Enables plugin functionality."))
if plugin.metadata.hasUserDisplay {
entries.append(.header(id: id.count, section: .pluginOptions, text: isRu ? "НАСТРОЙКИ ОТОБРАЖЕНИЯ" : "DISPLAY SETTINGS", badge: nil))
entries.append(.notice(id: id.count, section: .pluginOptions, text: isRu ? "Оставьте поля пустыми, чтобы использовать реальные данные. Пустой «Telegram ID пользователя» — свой профиль." : "Leave fields empty to use real data. Empty «User Telegram ID» means your own profile."))
for item in userDisplayBoolKeys {
let value = host.getPluginSettingBool(pluginId: pluginId, key: item.key, default: false)
let label = value ? (isRu ? "Вкл" : "On") : (isRu ? "Выкл" : "Off")
let text = "\(isRu ? item.titleRu : item.titleEn): \(label)"
entries.append(.action(id: id.count, section: .pluginOptions, actionType: "pluginBool:\(item.key)" as AnyHashable, text: text, kind: .generic))
}
for item in userDisplayStringKeys {
let value = host.getPluginSetting(pluginId: pluginId, key: item.key) ?? ""
let label = value.isEmpty ? (isRu ? "" : "") : value
let text = "\(isRu ? item.titleRu : item.titleEn): \(label)"
entries.append(.action(id: id.count, section: .pluginOptions, actionType: "pluginString:\(item.key)" as AnyHashable, text: text, kind: .generic))
}
} else if plugin.hasSettings {
entries.append(.header(id: id.count, section: .pluginOptions, text: isRu ? "НАСТРОЙКИ" : "SETTINGS", badge: nil))
entries.append(.notice(id: id.count, section: .pluginOptions, text: isRu ? "Настройки этого плагина задаются в файле .plugin (create_settings). Редактор для других типов плагинов в разработке." : "Settings for this plugin are defined in the .plugin file (create_settings). Editor for other plugin types coming later."))
}
entries.append(.header(id: id.count, section: .info, text: isRu ? "ИНФОРМАЦИЯ" : "INFORMATION", badge: nil))
entries.append(PluginSettingsEntry.notice(id: id.count, section: .info, text: "\(plugin.metadata.name)\n\(isRu ? "Версия" : "Version") \(plugin.metadata.version)\n\(plugin.metadata.author)\n\n\(plugin.metadata.description)"))
return entries
}
public func PluginSettingsController(context: AccountContext, plugin: PluginInfo, onSave: @escaping () -> Void) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
var backAction: (() -> Void)?
var presentAlertImpl: ((String, String, String, @escaping (String) -> Void) -> Void)?
let pluginId = plugin.metadata.id
let host = PluginHost.shared
let arguments = SGItemListArguments<SGBoolSetting, AnyHashable, AnyHashable, AnyHashable, AnyHashable>(
context: context,
setBoolValue: { _, _ in },
updateSliderValue: { _, _ in },
setOneFromManyValue: { _ in },
openDisclosureLink: { _ in },
action: { actionType in
guard let s = actionType as? String else { return }
if s == "toggleEnabled" {
var plugins = loadInstalledPlugins()
if let idx = plugins.firstIndex(where: { $0.metadata.id == pluginId }) {
plugins[idx].enabled.toggle()
saveInstalledPlugins(plugins)
reloadPromise.set(true)
onSave()
}
} else if s.hasPrefix("pluginBool:") {
let key = String(s.dropFirst("pluginBool:".count))
let current = host.getPluginSettingBool(pluginId: pluginId, key: key, default: false)
host.setPluginSettingBool(pluginId: pluginId, key: key, value: !current)
reloadPromise.set(true)
onSave()
} else if s.hasPrefix("pluginString:") {
let key = String(s.dropFirst("pluginString:".count))
let current = host.getPluginSetting(pluginId: pluginId, key: key) ?? ""
let titleRu = userDisplayStringKeys.first(where: { $0.key == key })?.titleRu ?? key
let titleEn = userDisplayStringKeys.first(where: { $0.key == key })?.titleEn ?? key
let lang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
let title = lang == "ru" ? titleRu : titleEn
presentAlertImpl?(key, title, current) { newValue in
host.setPluginSetting(pluginId: pluginId, key: key, value: newValue)
reloadPromise.set(true)
onSave()
}
}
},
searchInput: { _ in }
)
let signal = combineLatest(
reloadPromise.get(),
context.sharedContext.presentationData
)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, SGItemListArguments<SGBoolSetting, AnyHashable, AnyHashable, AnyHashable, AnyHashable>)) in
let plugins = loadInstalledPlugins()
let currentPlugin = plugins.first(where: { $0.metadata.id == plugin.metadata.id }) ?? plugin
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(currentPlugin.metadata.name),
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Back), style: .regular, enabled: true, action: { backAction?() }),
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = pluginSettingsEntries(presentationData: presentationData, plugin: currentPlugin)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
backAction = { [weak controller] in controller?.dismiss() }
presentAlertImpl = { [weak controller] key, title, currentValue, completion in
guard let c = controller else { return }
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
alert.addTextField { tf in
tf.text = currentValue
tf.placeholder = title
tf.autocapitalizationType = .none
tf.autocorrectionType = .no
}
let okTitle = context.sharedContext.currentPresentationData.with { $0 }.strings.Common_OK
let cancelTitle = context.sharedContext.currentPresentationData.with { $0 }.strings.Common_Cancel
alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel))
alert.addAction(UIAlertAction(title: okTitle, style: .default) { _ in
let newValue = alert.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
completion(newValue)
})
c.present(alert, animated: true)
}
return controller
}
+439
View File
@@ -0,0 +1,439 @@
// MARK: Swiftgram Profile cover: photo/video as profile background (visible only to you)
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
import AVFoundation
import ObjectiveC
import UniformTypeIdentifiers
private var profileCoverImagePickerDelegateKey: UInt8 = 0
private var profileCoverVideoPickerDelegateKey: UInt8 = 0
private var profileCoverDocumentPickerDelegateKey: UInt8 = 0
private let profileCoverSubdirectory = "ProfileCover"
private let profileCoverPhotoName = "cover.jpg"
private let profileCoverVideoName = "cover.mov"
/// Post when profile cover is saved so the profile screen can refresh the cover.
public extension Notification.Name {
static let SGProfileCoverDidChange = Notification.Name("SGProfileCoverDidChange")
}
private func profileCoverDirectoryURL() -> URL {
let support: URL
if #available(iOS 16.0, *) {
support = URL.applicationSupportDirectory
} else {
guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
fatalError("Application Support not available")
}
support = dir
}
return support.appendingPathComponent(profileCoverSubdirectory, isDirectory: true)
}
private func saveProfileCoverPhoto(from image: UIImage) throws -> String {
let fm = FileManager.default
let dir = profileCoverDirectoryURL()
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent(profileCoverPhotoName)
try? fm.removeItem(at: url)
guard let data = image.jpegData(compressionQuality: 0.85) else { throw NSError(domain: "ProfileCover", code: 1, userInfo: nil) }
try data.write(to: url)
return url.path
}
private func saveProfileCoverVideo(from sourceURL: URL) throws -> String {
let fm = FileManager.default
let dir = profileCoverDirectoryURL()
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
let dest = dir.appendingPathComponent(profileCoverVideoName)
try? fm.removeItem(at: dest)
try fm.copyItem(at: sourceURL, to: dest)
return dest.path
}
private func removeProfileCoverMedia() {
let fm = FileManager.default
let dir = profileCoverDirectoryURL()
try? fm.removeItem(at: dir.appendingPathComponent(profileCoverPhotoName))
try? fm.removeItem(at: dir.appendingPathComponent(profileCoverVideoName))
}
// MARK: - Preview row (image/video or placeholder)
private final class ProfileCoverPreviewItem: ListViewItem, ItemListItem {
let presentationData: ItemListPresentationData
let sectionId: ItemListSectionId
let coverPath: String
let isVideo: Bool
init(presentationData: ItemListPresentationData, sectionId: ItemListSectionId, coverPath: String, isVideo: Bool) {
self.presentationData = presentationData
self.sectionId = sectionId
self.coverPath = coverPath
self.isVideo = isVideo
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = ProfileCoverPreviewItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, { return (nil, { _ in apply(.None) }) })
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
guard let nodeValue = node() as? ProfileCoverPreviewItemNode else { return completion(ListViewItemNodeLayout(contentSize: .zero, insets: .zero), { _ in }) }
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
Queue.mainQueue().async {
completion(layout, { _ in apply(animation) })
}
}
}
}
var selectable: Bool { false }
static func == (lhs: ProfileCoverPreviewItem, rhs: ProfileCoverPreviewItem) -> Bool {
lhs.coverPath == rhs.coverPath && lhs.isVideo == rhs.isVideo && lhs.sectionId == rhs.sectionId
}
}
private final class ProfileCoverPreviewItemNode: ListViewItemNode {
private let backgroundNode: ASDisplayNode
private let imageNode: ASImageNode
private let placeholderLabel: ImmediateTextNode
private var item: ProfileCoverPreviewItem?
init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.imageNode = ASImageNode()
self.imageNode.contentMode = .scaleAspectFill
self.imageNode.clipsToBounds = true
self.placeholderLabel = ImmediateTextNode()
super.init(layerBacked: false)
addSubnode(backgroundNode)
addSubnode(imageNode)
addSubnode(placeholderLabel)
}
func asyncLayout() -> (ProfileCoverPreviewItem, ListViewItemLayoutParams, ItemListNeighbors) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) {
return { item, params, neighbors in
let height: CGFloat = 180.0
let contentSize = CGSize(width: params.width, height: height)
let insets = itemListNeighborsGroupedInsets(neighbors, params)
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
return (layout, { [weak self] animation in
guard let self else { return }
self.item = item
self.backgroundNode.backgroundColor = item.presentationData.theme.list.itemBlocksBackgroundColor
self.backgroundNode.frame = CGRect(origin: .zero, size: contentSize)
self.imageNode.frame = CGRect(x: params.leftInset, y: 0, width: params.width - params.leftInset - params.rightInset, height: height)
self.imageNode.isHidden = item.coverPath.isEmpty
if !item.coverPath.isEmpty {
if item.isVideo {
self.loadVideoThumbnail(path: item.coverPath)
} else {
self.imageNode.image = UIImage(contentsOfFile: item.coverPath)
}
} else {
self.imageNode.image = nil
self.placeholderLabel.attributedText = NSAttributedString(string: item.presentationData.strings.baseLanguageCode == "ru" ? "Обложка не выбрана" : "No cover selected", font: Font.regular(15), textColor: item.presentationData.theme.list.itemSecondaryTextColor)
let labelSize = self.placeholderLabel.updateLayout(CGSize(width: params.width - 32, height: 60))
self.placeholderLabel.frame = CGRect(x: (params.width - labelSize.width) / 2, y: (height - labelSize.height) / 2, width: labelSize.width, height: labelSize.height)
self.placeholderLabel.isHidden = false
}
self.placeholderLabel.isHidden = !item.coverPath.isEmpty
})
}
}
private func loadVideoThumbnail(path: String) {
let url = URL(fileURLWithPath: path)
let asset = AVAsset(url: url)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 400, height: 400)
generator.generateCGImagesAsynchronously(forTimes: [NSValue(time: .zero)]) { [weak self] _, cgImage, _, _, _ in
Queue.mainQueue().async {
self?.imageNode.image = cgImage.flatMap { UIImage(cgImage: $0) }
}
}
}
}
// MARK: - Controller
private enum ProfileCoverEntry: ItemListNodeEntry {
case previewHeader(id: Int, text: String)
case preview(id: Int, path: String, isVideo: Bool)
case mediaHeader(id: Int, text: String)
case uploadPhoto(id: Int, text: String)
case setVideo(id: Int, text: String)
case uploadFromFiles(id: Int, text: String)
case deleteMedia(id: Int, text: String)
var id: Int { stableId }
var section: ItemListSectionId {
switch self {
case .previewHeader, .preview: return 0
default: return 1
}
}
var stableId: Int {
switch self {
case .previewHeader(let i, _), .preview(let i, _, _), .mediaHeader(let i, _), .uploadPhoto(let i, _), .setVideo(let i, _), .uploadFromFiles(let i, _), .deleteMedia(let i, _): return i
}
}
static func < (lhs: ProfileCoverEntry, rhs: ProfileCoverEntry) -> Bool { lhs.stableId < rhs.stableId }
static func == (lhs: ProfileCoverEntry, rhs: ProfileCoverEntry) -> Bool {
switch (lhs, rhs) {
case let (.previewHeader(a, t1), .previewHeader(b, t2)): return a == b && t1 == t2
case let (.preview(a, p1, v1), .preview(b, p2, v2)): return a == b && p1 == p2 && v1 == v2
case let (.mediaHeader(a, t1), .mediaHeader(b, t2)): return a == b && t1 == t2
case let (.uploadPhoto(a, t1), .uploadPhoto(b, t2)): return a == b && t1 == t2
case let (.setVideo(a, t1), .setVideo(b, t2)): return a == b && t1 == t2
case let (.uploadFromFiles(a, t1), .uploadFromFiles(b, t2)): return a == b && t1 == t2
case let (.deleteMedia(a, t1), .deleteMedia(b, t2)): return a == b && t1 == t2
default: return false
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! ProfileCoverArguments
switch self {
case .previewHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .preview(_, let path, let isVideo):
return ProfileCoverPreviewItem(presentationData: presentationData, sectionId: self.section, coverPath: path, isVideo: isVideo)
case .mediaHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .uploadPhoto(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.uploadPhoto() })
case .setVideo(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.setVideo() })
case .uploadFromFiles(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.uploadFromFiles() })
case .deleteMedia(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.deleteMedia() })
}
}
}
private final class ProfileCoverArguments {
let uploadPhoto: () -> Void
let setVideo: () -> Void
let uploadFromFiles: () -> Void
let deleteMedia: () -> Void
init(uploadPhoto: @escaping () -> Void, setVideo: @escaping () -> Void, uploadFromFiles: @escaping () -> Void, deleteMedia: @escaping () -> Void) {
self.uploadPhoto = uploadPhoto
self.setVideo = setVideo
self.uploadFromFiles = uploadFromFiles
self.deleteMedia = deleteMedia
}
}
private func profileCoverEntries(presentationData: PresentationData, path: String, isVideo: Bool) -> [ProfileCoverEntry] {
let lang = presentationData.strings.baseLanguageCode
var list: [ProfileCoverEntry] = []
var id = 0
list.append(.previewHeader(id: id, text: lang == "ru" ? "ПРЕДПРОСМОТР" : "PREVIEW"))
id += 1
list.append(.preview(id: id, path: path, isVideo: isVideo))
id += 1
list.append(.mediaHeader(id: id, text: lang == "ru" ? "МЕДИА" : "MEDIA"))
id += 1
list.append(.uploadPhoto(id: id, text: lang == "ru" ? "Загрузить фото" : "Upload photo"))
id += 1
list.append(.setVideo(id: id, text: lang == "ru" ? "Установить видео" : "Set video"))
id += 1
list.append(.uploadFromFiles(id: id, text: lang == "ru" ? "Загрузить из файлов" : "Load from files"))
id += 1
list.append(.deleteMedia(id: id, text: lang == "ru" ? "Удалить медиа" : "Delete media"))
return list
}
public func ProfileCoverController(context: AccountContext) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
var presentImagePicker: (() -> Void)?
var presentVideoPicker: (() -> Void)?
var presentDocumentPicker: (() -> Void)?
var backAction: (() -> Void)?
let arguments = ProfileCoverArguments(
uploadPhoto: { presentImagePicker?() },
setVideo: { presentVideoPicker?() },
uploadFromFiles: { presentDocumentPicker?() },
deleteMedia: {
removeProfileCoverMedia()
SGSimpleSettings.shared.profileCoverMediaPath = ""
SGSimpleSettings.shared.profileCoverIsVideo = false
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
}
)
let signal = combineLatest(reloadPromise.get(), context.sharedContext.presentationData)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, ProfileCoverArguments)) in
let path = SGSimpleSettings.shared.profileCoverMediaPath
let isVideo = SGSimpleSettings.shared.profileCoverIsVideo
let lang = presentationData.strings.baseLanguageCode
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(lang == "ru" ? "Обложка профиля" : "Profile cover"),
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Back), style: .regular, enabled: true, action: { backAction?() }),
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = profileCoverEntries(presentationData: presentationData, path: path, isVideo: isVideo)
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: nil, initialScrollToItem: nil)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
backAction = { [weak controller] in controller?.dismiss() }
presentImagePicker = { [weak controller] in
guard let controller = controller else { return }
// UIImagePickerController надёжнее PHPicker при выборе из галереи (iOS 16+)
let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
picker.mediaTypes = ["public.image"]
let delegate = ProfileCoverImagePickerDelegate(
onPick: { image in
do {
let savedPath = try saveProfileCoverPhoto(from: image)
SGSimpleSettings.shared.profileCoverMediaPath = savedPath
SGSimpleSettings.shared.profileCoverIsVideo = false
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
NotificationCenter.default.post(name: .SGProfileCoverDidChange, object: nil)
} catch {}
}
)
picker.delegate = delegate
objc_setAssociatedObject(picker, &profileCoverImagePickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
presentVideoPicker = { [weak controller] in
guard let controller = controller else { return }
let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
picker.mediaTypes = ["public.movie"]
picker.videoMaximumDuration = 30
let delegate = ProfileCoverVideoPickerDelegate(
onPick: { url in
let needsStop = url.startAccessingSecurityScopedResource()
defer { if needsStop { url.stopAccessingSecurityScopedResource() } }
do {
let savedPath = try saveProfileCoverVideo(from: url)
SGSimpleSettings.shared.profileCoverMediaPath = savedPath
SGSimpleSettings.shared.profileCoverIsVideo = true
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
NotificationCenter.default.post(name: .SGProfileCoverDidChange, object: nil)
} catch {}
}
)
picker.delegate = delegate
objc_setAssociatedObject(picker, &profileCoverVideoPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
presentDocumentPicker = { [weak controller] in
guard let controller = controller else { return }
let onPick: (URL) -> Void = { url in
// With asCopy: true the file is in app sandbox; only some sources need security-scoped access
let needsStop = url.startAccessingSecurityScopedResource()
defer { if needsStop { url.stopAccessingSecurityScopedResource() } }
let ext = url.pathExtension.lowercased()
let isVideo = ["mov", "mp4", "m4v"].contains(ext)
if isVideo {
do {
let savedPath = try saveProfileCoverVideo(from: url)
SGSimpleSettings.shared.profileCoverMediaPath = savedPath
SGSimpleSettings.shared.profileCoverIsVideo = true
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
NotificationCenter.default.post(name: .SGProfileCoverDidChange, object: nil)
} catch {}
} else {
guard let data = try? Data(contentsOf: url), let image = UIImage(data: data) else { return }
do {
let savedPath = try saveProfileCoverPhoto(from: image)
SGSimpleSettings.shared.profileCoverMediaPath = savedPath
SGSimpleSettings.shared.profileCoverIsVideo = false
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
NotificationCenter.default.post(name: .SGProfileCoverDidChange, object: nil)
} catch {}
}
}
if #available(iOS 14.0, *) {
let types: [UTType] = [.image, .movie]
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
let delegate = ProfileCoverDocumentPickerDelegate(onPick: onPick)
picker.delegate = delegate
objc_setAssociatedObject(picker, &profileCoverDocumentPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
} else {
let picker = UIDocumentPickerViewController(documentTypes: ["public.image", "public.movie"], in: .import)
let delegate = ProfileCoverDocumentPickerDelegate(onPick: onPick)
picker.delegate = delegate
objc_setAssociatedObject(picker, &profileCoverDocumentPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
}
return controller
}
private final class ProfileCoverImagePickerDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onPick: (UIImage) -> Void
init(onPick: @escaping (UIImage) -> Void) { self.onPick = onPick }
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true)
guard let image = info[.originalImage] as? UIImage else { return }
onPick(image)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
private final class ProfileCoverVideoPickerDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onPick: (URL) -> Void
init(onPick: @escaping (URL) -> Void) { self.onPick = onPick }
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true)
guard let url = info[.mediaURL] as? URL else { return }
onPick(url)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
private final class ProfileCoverDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate {
let onPick: (URL) -> Void
init(onPick: @escaping (URL) -> Void) { self.onPick = onPick }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
onPick(url)
}
}
@@ -0,0 +1,293 @@
// MARK: Swiftgram - Saved Deleted Messages List
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
#if canImport(SGDeletedMessages)
import SGDeletedMessages
#endif
// MARK: - GLEGram
// MARK: - Entry
private enum SavedDeletedListEntry: ItemListNodeEntry {
case search(id: Int, query: String)
case empty(id: Int, text: String)
case peerHeader(id: Int, sectionIndex: Int32, text: String)
case messageRow(id: Int, sectionIndex: Int32, text: String, dateText: String, peerId: PeerId, messageId: MessageId, searchableText: String)
case deleteAction(id: Int, sectionIndex: Int32, text: String, peerId: PeerId)
var stableId: Int {
switch self {
case .search(let id, _): return id
case .empty(let id, _): return id
case .peerHeader(let id, _, _): return id
case .messageRow(let id, _, _, _, _, _, _): return id
case .deleteAction(let id, _, _, _): return id
}
}
var section: ItemListSectionId {
switch self {
case .search(_, _): return 0
case .empty: return 0
case .peerHeader(_, let s, _): return s
case .messageRow(_, let s, _, _, _, _, _): return s
case .deleteAction(_, let s, _, _): return s
}
}
static func < (lhs: SavedDeletedListEntry, rhs: SavedDeletedListEntry) -> Bool {
lhs.stableId < rhs.stableId
}
static func == (lhs: SavedDeletedListEntry, rhs: SavedDeletedListEntry) -> Bool {
switch (lhs, rhs) {
case let (.search(a, q1), .search(b, q2)): return a == b && q1 == q2
case let (.empty(a, t1), .empty(b, t2)): return a == b && t1 == t2
case let (.peerHeader(a, s1, t1), .peerHeader(b, s2, t2)): return a == b && s1 == s2 && t1 == t2
case let (.messageRow(a, s1, t1, d1, p1, m1, _), .messageRow(b, s2, t2, d2, p2, m2, _)): return a == b && s1 == s2 && t1 == t2 && d1 == d2 && p1 == p2 && m1 == m2
case let (.deleteAction(a, s1, t1, p1), .deleteAction(b, s2, t2, p2)): return a == b && s1 == s2 && t1 == t2 && p1 == p2
default: return false
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! SavedDeletedListArguments
switch self {
case .search(_, let query):
return ItemListSingleLineInputItem(presentationData: presentationData, title: NSAttributedString(string: ""), text: query, placeholder: presentationData.strings.Common_Search, type: .regular(capitalization: false, autocorrection: false), spacing: 0.0, clearType: .always, tag: nil, sectionId: section, textUpdated: { args.searchUpdated($0) }, action: {})
case .empty(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
case .peerHeader(_, _, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: section)
case .messageRow(_, _, let text, let dateText, let peerId, let messageId, _):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: dateText, sectionId: section, style: .blocks, action: {
args.openMessage(peerId, messageId)
})
case .deleteAction(_, _, let text, let peerId):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .destructive, alignment: .natural, sectionId: section, style: .blocks, action: {
args.deleteMessagesForPeer(peerId)
})
}
}
}
// MARK: - Arguments
private final class SearchQueryRef {
var value: String = ""
}
private final class SavedDeletedListArguments {
let searchQueryRef: SearchQueryRef
var searchQuery: String { searchQueryRef.value }
let searchUpdated: (String) -> Void
let deleteMessagesForPeer: (PeerId) -> Void
let openMessage: (PeerId, MessageId) -> Void
init(searchQueryRef: SearchQueryRef, searchUpdated: @escaping (String) -> Void, deleteMessagesForPeer: @escaping (PeerId) -> Void, openMessage: @escaping (PeerId, MessageId) -> Void) {
self.searchQueryRef = searchQueryRef
self.searchUpdated = searchUpdated
self.deleteMessagesForPeer = deleteMessagesForPeer
self.openMessage = openMessage
}
}
// MARK: - Date formatting
private let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
// MARK: - Entries builder (full list, no filter)
#if canImport(SGDeletedMessages)
private func savedDeletedListEntries(
data: [(peer: Peer?, peerId: PeerId, messages: [Message])],
lang: String
) -> [SavedDeletedListEntry] {
var entries: [SavedDeletedListEntry] = []
var id = 0
entries.append(.search(id: id, query: ""))
id += 1
if data.isEmpty {
let text = (lang == "ru" ? "Нет сохранённых удалённых сообщений." : "No saved deleted messages.")
entries.append(.empty(id: id, text: text))
return entries
}
var sectionIndex: Int32 = 0
for group in data {
let peerName: String
if let peer = group.peer {
peerName = peer.debugDisplayTitle
} else {
peerName = "Peer \(group.peerId.id._internalGetInt64Value())"
}
sectionIndex += 1
let countStr = lang == "ru" ? "\(group.messages.count) сообщ." : "\(group.messages.count) msg"
entries.append(.peerHeader(id: id, sectionIndex: sectionIndex, text: "\(peerName.uppercased()) (\(countStr))"))
id += 1
for message in group.messages {
let text = message.text.isEmpty
? (lang == "ru" ? "[медиа]" : "[media]")
: String(message.text.prefix(120)).replacingOccurrences(of: "\n", with: " ")
let searchableText = (message.text + " " + (message.sgDeletedAttribute.originalText ?? "")).trimmingCharacters(in: .whitespacesAndNewlines)
let date = dateFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(message.timestamp)))
entries.append(.messageRow(id: id, sectionIndex: sectionIndex, text: text, dateText: date, peerId: group.peerId, messageId: message.id, searchableText: searchableText))
id += 1
}
let deleteText = lang == "ru" ? "Удалить все для этого чата" : "Delete all for this chat"
entries.append(.deleteAction(id: id, sectionIndex: sectionIndex, text: deleteText, peerId: group.peerId))
id += 1
}
return entries
}
/// Filter by search query - two-pass, keep search, keep sections that have matches.
private func filterSavedDeletedListEntries(_ entries: [SavedDeletedListEntry], by searchQuery: String?, lang: String) -> [SavedDeletedListEntry] {
guard let query = searchQuery?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !query.isEmpty else {
return entries
}
var sectionIdsWithMatches: Set<Int32> = []
for entry in entries {
switch entry {
case .search(_, _), .empty:
break
case .peerHeader(_, let s, let text):
if text.lowercased().contains(query) { sectionIdsWithMatches.insert(s) }
case .messageRow(_, let s, _, let dateText, _, _, let searchableText):
if searchableText.lowercased().contains(query) || dateText.lowercased().contains(query) { sectionIdsWithMatches.insert(s) }
case .deleteAction(_, let s, let text, _):
if text.lowercased().contains(query) { sectionIdsWithMatches.insert(s) }
}
}
var filtered: [SavedDeletedListEntry] = []
for entry in entries {
switch entry {
case .search(_, _):
filtered.append(entry)
case .empty:
continue
case .peerHeader(_, let s, _), .messageRow(_, let s, _, _, _, _, _), .deleteAction(_, let s, _, _):
if sectionIdsWithMatches.contains(s) {
filtered.append(entry)
}
}
}
if filtered.count == 1, case .search(_, _) = filtered[0] {
filtered.append(.empty(id: Int.max, text: lang == "ru" ? "Ничего не найдено." : "No results."))
}
return filtered
}
#endif
// MARK: - Controller
public func savedDeletedMessagesListController(context: AccountContext) -> ViewController {
#if canImport(SGDeletedMessages)
var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)?
var pushControllerImpl: ((ViewController) -> Void)?
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
let searchQueryPromise = ValuePromise("", ignoreRepeated: false)
let searchQueryRef = SearchQueryRef()
let arguments = SavedDeletedListArguments(
searchQueryRef: searchQueryRef,
searchUpdated: { value in
searchQueryRef.value = value
searchQueryPromise.set(value)
},
deleteMessagesForPeer: { peerId in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
let title = lang == "ru" ? "Удалить" : "Delete"
let text = lang == "ru" ? "Удалить все сохранённые удалённые сообщения для этого чата?" : "Delete all saved deleted messages for this chat?"
let alert = textAlertController(
context: context,
title: title,
text: text,
actions: [
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
let _ = (SGDeletedMessages.getAllSavedDeletedMessages(postbox: context.account.postbox)
|> mapToSignal { groups -> Signal<Void, NoError> in
var idsToDelete: [MessageId] = []
for group in groups where group.peerId == peerId {
idsToDelete.append(contentsOf: group.messages.map { $0.id })
}
return SGDeletedMessages.deleteSavedDeletedMessages(ids: idsToDelete, postbox: context.account.postbox)
}
|> deliverOnMainQueue).start(completed: {
reloadPromise.set(true)
})
}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
]
)
presentControllerImpl?(alert, nil)
},
openMessage: { peerId, messageId in
let chatController = context.sharedContext.makeChatController(context: context, chatLocation: .peer(id: peerId), subject: .message(id: .id(messageId), highlight: nil, timecode: nil, setupReply: false), botStart: nil, mode: .standard(.default), params: nil)
pushControllerImpl?(chatController)
}
)
let dataSignal = reloadPromise.get()
|> mapToSignal { _ -> Signal<[(peer: Peer?, peerId: PeerId, messages: [Message])], NoError> in
return SGDeletedMessages.getAllSavedDeletedMessages(postbox: context.account.postbox)
}
let signal = combineLatest(dataSignal, searchQueryPromise.get(), context.sharedContext.presentationData)
|> map { data, searchQuery, presentationData -> (ItemListControllerState, (ItemListNodeState, SavedDeletedListArguments)) in
let lang = presentationData.strings.baseLanguageCode
let title = lang == "ru" ? "Сохранённые удалённые" : "Saved Deleted"
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(title),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let allEntries = savedDeletedListEntries(data: data, lang: lang)
let entriesWithQuery = allEntries.map { entry -> SavedDeletedListEntry in
if case .search(let id, _) = entry { return .search(id: id, query: searchQuery) }
return entry
}
let entries = filterSavedDeletedListEntries(entriesWithQuery, by: searchQuery, lang: lang)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
presentControllerImpl = { [weak controller] c, a in
controller?.present(c, in: PresentationContextType.window(PresentationSurfaceLevel.root), with: a)
}
pushControllerImpl = { [weak controller] c in
controller?.navigationController?.pushViewController(c, animated: true)
}
return controller
#else
return ViewController(navigationBarPresentationData: nil)
#endif
}
+101
View File
@@ -0,0 +1,101 @@
// MARK: Swiftgram Load .dylib tweaks at startup (no Python, no .plugin)
import Foundation
import SGSimpleSettings
#if canImport(Darwin)
import Darwin
#else
import Glibc
#endif
/// Directory where user-installed .dylib tweaks are stored. Tweaks are loaded on next app launch.
public enum TweakLoader {
private static let tweaksSubdirectory = "Tweaks"
/// URL to Application Support/Tweaks (call from main thread or after app container is available).
public static var tweaksDirectoryURL: URL {
let fileManager = FileManager.default
guard let support = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
fatalError("Application Support directory not available")
}
return support.appendingPathComponent(tweaksSubdirectory, isDirectory: true)
}
/// Ensure Tweaks directory exists; returns its URL.
@discardableResult
public static func ensureTweaksDirectory() -> URL {
let url = tweaksDirectoryURL
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
/// List installed tweak filenames (.dylib) in the Tweaks directory.
public static func installedTweakFilenames() -> [String] {
let url = tweaksDirectoryURL
guard let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else {
return []
}
return contents
.filter { $0.pathExtension.lowercased() == "dylib" }
.map { $0.lastPathComponent }
.sorted()
}
/// Copy a .dylib file into the Tweaks directory. Returns destination URL on success.
public static func installTweak(from sourceURL: URL) throws -> URL {
let fileManager = FileManager.default
let dir = ensureTweaksDirectory()
let name = sourceURL.lastPathComponent
guard name.lowercased().hasSuffix(".dylib") else {
throw NSError(domain: "TweakLoader", code: 1, userInfo: [NSLocalizedDescriptionKey: "Not a .dylib file"])
}
let dest = dir.appendingPathComponent(name)
if fileManager.fileExists(atPath: dest.path) {
try fileManager.removeItem(at: dest)
}
try fileManager.copyItem(at: sourceURL, to: dest)
return dest
}
/// Remove a tweak by filename (e.g. "TGExtra.dylib").
public static func removeTweak(filename: String) throws {
let url = tweaksDirectoryURL.appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
}
/// Load all .dylib files from the Tweaks directory. Call once at app startup when pluginSystemEnabled.
/// On iOS, loading dylibs from a writable path may require jailbreak or special entitlements.
public static func loadTweaks() {
guard SGSimpleSettings.shared.pluginSystemEnabled else { return }
let dir = tweaksDirectoryURL
guard let contents = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else {
return
}
let dylibs = contents.filter { $0.pathExtension.lowercased() == "dylib" }
for url in dylibs {
loadTweak(at: url)
}
}
private static func loadTweak(at url: URL) {
let path = url.path
#if canImport(Darwin)
guard let handle = dlopen(path, RTLD_NOW | RTLD_LOCAL) else {
if let err = dlerror() {
NSLog("[TweakLoader] Failed to load %@: %s", path, err)
}
return
}
// Optional: call an init symbol if the tweak exports it (e.g. GLEGramTweakInit).
if let initSymbol = dlsym(handle, "GLEGramTweakInit") {
typealias InitFn = @convention(c) () -> Void
let fn = unsafeBitCast(initSymbol, to: InitFn.self)
fn()
}
// Keep handle alive (we don't dlclose; tweaks stay loaded for app lifetime).
_ = handle
#endif
}
}
+17
View File
@@ -0,0 +1,17 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGChatExport",
module_name = "SGChatExport",
srcs = glob(["Sources/**/*.swift"]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Postbox:Postbox",
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/TelegramCore:TelegramCore",
"//Swiftgram/SGLogging:SGLogging",
],
visibility = ["//visibility:public"],
)
@@ -0,0 +1,730 @@
import Foundation
import Postbox
import SwiftSignalKit
import TelegramCore
#if canImport(SGLogging)
import SGLogging
#endif
// MARK: - Constants
private let messagesPerPage = 1000
// MARK: - Export Progress
public enum SGChatExportProgress {
case preparing
case exporting(current: Int, total: Int)
case copyingMedia(current: Int, total: Int)
case done(URL)
case error(String)
}
// MARK: - Peer Display Name Helper
private func peerDisplayName(_ peer: Peer?) -> String {
guard let peer = peer else { return "Unknown" }
if let user = peer as? TelegramUser {
let first = user.firstName ?? ""
let last = user.lastName ?? ""
let name = [first, last].filter { !$0.isEmpty }.joined(separator: " ")
return name.isEmpty ? (user.username ?? "User") : name
} else if let channel = peer as? TelegramChannel {
return channel.title
} else if let group = peer as? TelegramGroup {
return group.title
}
return "Chat"
}
private func peerInitial(_ peer: Peer?) -> String {
let name = peerDisplayName(peer)
return String(name.prefix(1))
}
private func userpicColorIndex(_ peer: Peer?) -> Int {
guard let peer = peer else { return 1 }
let id = peer.id.id._internalGetInt64Value()
return Int(abs(id) % 8) + 1
}
// MARK: - Chat Title Helper
private func chatTitle(peerId: PeerId, transaction: Transaction) -> String {
if let peer = transaction.getPeer(peerId) {
return peerDisplayName(peer)
}
return "Chat"
}
// MARK: - HTML Escaping
private func htmlEscape(_ text: String) -> String {
return text
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
// MARK: - Date Formatting
private let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "dd.MM.yyyy HH:mm:ss"
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
private let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm"
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
private let dateSeparatorFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "d MMMM yyyy"
f.locale = Locale(identifier: "en_US")
return f
}()
private let fileDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "dd-MM-yyyy_HH-mm-ss"
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
private func timeZoneSuffix() -> String {
let tz = TimeZone.current
let seconds = tz.secondsFromGMT()
let hours = seconds / 3600
let minutes = abs(seconds % 3600) / 60
return String(format: "UTC%+03d:%02d", hours, minutes)
}
// MARK: - Duration Formatting
private func formatDuration(_ seconds: Int) -> String {
let m = seconds / 60
let s = seconds % 60
return String(format: "%02d:%02d", m, s)
}
// MARK: - Media Info
private struct MediaFileInfo {
let sourceResourcePath: String?
let exportSubdir: String
let exportFileName: String
let htmlBlock: String
}
private func mediaInfoForMessage(
_ message: Message,
mediaBox: MediaBox,
messageDate: Date
) -> MediaFileInfo? {
for media in message.media {
if let image = media as? TelegramMediaImage {
guard let largest = image.representations.last else { continue }
let sourcePath = mediaBox.completedResourcePath(largest.resource, pathExtension: "jpg")
let dateStr = fileDateFormatter.string(from: messageDate)
let fileName = "photo_\(message.id.id)@\(dateStr).jpg"
let thumbFileName = "photo_\(message.id.id)@\(dateStr)_thumb.jpg"
let dims = largest.dimensions
let w = min(Int(dims.width), 260)
let h = Int(Double(dims.height) * Double(w) / max(Double(dims.width), 1))
let html = """
<div class="media_wrap clearfix">
<a class="photo_wrap clearfix pull_left" href="photos/\(fileName)">
<img class="photo" src="photos/\(thumbFileName)" style="width: \(w)px; height: \(h)px"/>
</a>
</div>
"""
return MediaFileInfo(
sourceResourcePath: sourcePath,
exportSubdir: "photos",
exportFileName: fileName,
htmlBlock: html
)
}
if let file = media as? TelegramMediaFile {
let sourcePath = mediaBox.completedResourcePath(file.resource)
let dateStr = fileDateFormatter.string(from: messageDate)
if file.isVoice {
let duration = Int(file.duration ?? 0)
let fileName = "audio_\(message.id.id)@\(dateStr).ogg"
let html = """
<div class="media_wrap clearfix">
<a class="media clearfix pull_left block_link media_voice_message" href="voice_messages/\(fileName)">
<div class="fill pull_left"></div>
<div class="body">
<div class="title bold">Voice message</div>
<div class="status details">\(formatDuration(duration))</div>
</div>
</a>
</div>
"""
return MediaFileInfo(
sourceResourcePath: sourcePath,
exportSubdir: "voice_messages",
exportFileName: fileName,
htmlBlock: html
)
}
if file.isInstantVideo {
let duration = Int(file.duration ?? 0)
let fileName = "round_\(message.id.id)@\(dateStr).mp4"
let html = """
<div class="media_wrap clearfix">
<div class="video_file_wrap clearfix pull_left">
<a href="round_video_messages/\(fileName)">
<div class="video_play_bg"><div class="video_play"></div></div>
</a>
<div class="video_duration">\(formatDuration(duration))</div>
</div>
</div>
"""
return MediaFileInfo(
sourceResourcePath: sourcePath,
exportSubdir: "round_video_messages",
exportFileName: fileName,
htmlBlock: html
)
}
if file.isSticker {
let fileName = "sticker_\(message.id.id)@\(dateStr).webp"
let html = """
<div class="media_wrap clearfix">
<a href="stickers/\(fileName)">
<img class="sticker" src="stickers/\(fileName)" style="width: 256px; height: 256px"/>
</a>
</div>
"""
return MediaFileInfo(
sourceResourcePath: sourcePath,
exportSubdir: "stickers",
exportFileName: fileName,
htmlBlock: html
)
}
if file.isVideo {
let duration = Int(file.duration ?? 0)
let fileName = "video_\(message.id.id)@\(dateStr).mp4"
let html = """
<div class="media_wrap clearfix">
<div class="video_file_wrap clearfix pull_left">
<a href="video_files/\(fileName)">
<div class="video_play_bg"><div class="video_play"></div></div>
</a>
<div class="video_duration">\(formatDuration(duration))</div>
</div>
</div>
"""
return MediaFileInfo(
sourceResourcePath: sourcePath,
exportSubdir: "video_files",
exportFileName: fileName,
htmlBlock: html
)
}
// Generic file
let origName = file.fileName ?? "file_\(message.id.id)"
let fileName = origName
let fileSize = file.size ?? 0
let sizeStr = ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file)
let html = """
<div class="media_wrap clearfix">
<a class="media clearfix pull_left block_link media_file" href="files/\(htmlEscape(fileName))">
<div class="fill pull_left"></div>
<div class="body">
<div class="title bold">\(htmlEscape(origName))</div>
<div class="status details">\(sizeStr)</div>
</div>
</a>
</div>
"""
return MediaFileInfo(
sourceResourcePath: sourcePath,
exportSubdir: "files",
exportFileName: fileName,
htmlBlock: html
)
}
}
return nil
}
// MARK: - Reactions HTML
private func reactionsHTML(for message: Message) -> String {
var reactionsAttr: ReactionsMessageAttribute?
for attr in message.attributes {
if let r = attr as? ReactionsMessageAttribute {
reactionsAttr = r
break
}
}
guard let reactionsAttr = reactionsAttr, !reactionsAttr.reactions.isEmpty else {
return ""
}
var html = "\n <span class=\"reactions\">\n"
for reaction in reactionsAttr.reactions {
let isActive = reaction.chosenOrder != nil
let activeClass = isActive ? " active" : ""
var emojiStr = ""
switch reaction.value {
case let .builtin(emoji):
emojiStr = emoji
case .custom:
emojiStr = "\u{2764}\u{FE0F}"
case .stars:
emojiStr = "\u{2B50}"
}
html += " <span class=\"reaction\(activeClass)\">\n"
html += " <span class=\"emoji\">\(emojiStr)</span>\n"
html += " <span class=\"count\">\(reaction.count)</span>\n"
html += " </span>\n"
}
html += " </span>\n"
return html
}
// MARK: - Reply HTML
private func replyHTML(for message: Message) -> String {
for attr in message.attributes {
if let replyAttr = attr as? ReplyMessageAttribute {
let replyId = replyAttr.messageId.id
return """
<div class="reply_to details">
In reply to <a href="#go_to_message\(replyId)" onclick="return GoToMessage(\(replyId))">this message</a>
</div>
"""
}
}
return ""
}
// MARK: - Forward Info HTML
private func forwardHTML(
_ message: Message,
mediaHTML: String,
textHTML: String
) -> String {
guard let fwd = message.forwardInfo else { return "" }
let authorName = htmlEscape(peerDisplayName(fwd.author))
let authorInitial = peerInitial(fwd.author)
let authorColor = userpicColorIndex(fwd.author)
let fwdDate = Date(timeIntervalSince1970: Double(fwd.date))
let fwdDateStr = dateFormatter.string(from: fwdDate)
var body = ""
body += """
<div class="pull_left forwarded userpic_wrap">
<div class="userpic userpic\(authorColor)" style="width: 42px; height: 42px">
<div class="initials" style="line-height: 42px">\(htmlEscape(authorInitial))</div>
</div>
</div>
<div class="forwarded body">
<div class="from_name">\(authorName) <span class="date details" title="\(fwdDateStr)"> \(fwdDateStr)</span></div>
"""
if !mediaHTML.isEmpty {
body += mediaHTML + "\n"
}
if !textHTML.isEmpty {
body += " <div class=\"text\">\(textHTML)</div>\n"
}
body += " </div>\n"
return body
}
// MARK: - Message Text Processing
private func processMessageText(_ text: String) -> String {
guard !text.isEmpty else { return "" }
// Escape HTML and convert newlines
var result = htmlEscape(text)
result = result.replacingOccurrences(of: "\n", with: "<br>")
// Basic URL detection and linking
if let urlPattern = try? NSRegularExpression(pattern: "(https?://[^\\s<>]+)", options: []) {
let range = NSRange(result.startIndex..., in: result)
result = urlPattern.stringByReplacingMatches(
in: result,
options: [],
range: range,
withTemplate: "<a href=\"$1\">$1</a>"
)
}
return result
}
// MARK: - Service Message Text
private func serviceMessageText(_ message: Message) -> String? {
for media in message.media {
if let action = media as? TelegramMediaAction {
switch action.action {
case let .groupCreated(title):
let authorName = peerDisplayName(message.author)
return "\(htmlEscape(authorName)) created group &laquo;\(htmlEscape(title))&raquo;"
case .pinnedMessageUpdated:
let authorName = peerDisplayName(message.author)
return "\(htmlEscape(authorName)) pinned a message"
case let .addedMembers(peerIds):
let authorName = peerDisplayName(message.author)
let memberNames = peerIds.compactMap { id -> String? in
if let peer = message.peers[id] {
return peerDisplayName(peer)
}
return nil
}
if memberNames.isEmpty {
return "\(htmlEscape(authorName)) added members"
}
return "\(htmlEscape(authorName)) added \(memberNames.map { htmlEscape($0) }.joined(separator: ", "))"
case let .removedMembers(peerIds):
let authorName = peerDisplayName(message.author)
let memberNames = peerIds.compactMap { id -> String? in
if let peer = message.peers[id] {
return peerDisplayName(peer)
}
return nil
}
if memberNames.isEmpty {
return "\(htmlEscape(authorName)) removed a member"
}
return "\(htmlEscape(authorName)) removed \(memberNames.map { htmlEscape($0) }.joined(separator: ", "))"
case .joinedByLink:
let authorName = peerDisplayName(message.author)
return "\(htmlEscape(authorName)) joined group by link"
case let .photoUpdated(photo):
let authorName = peerDisplayName(message.author)
if photo != nil {
return "\(htmlEscape(authorName)) changed group photo"
}
return "\(htmlEscape(authorName)) removed group photo"
case let .titleUpdated(title):
let authorName = peerDisplayName(message.author)
return "\(htmlEscape(authorName)) changed group name to &laquo;\(htmlEscape(title))&raquo;"
case .historyCleared:
return "History cleared"
case let .channelMigratedFromGroup(title, _):
return "Group &laquo;\(htmlEscape(title))&raquo; converted to supergroup"
case .groupMigratedToChannel:
return "Group converted to supergroup"
case let .topicCreated(title, _, _):
return "Topic &laquo;\(htmlEscape(title))&raquo; created"
case let .phoneCall(_, _, duration, isVideo):
let authorName = peerDisplayName(message.author)
let callType = isVideo ? "video call" : "call"
if let duration = duration, duration > 0 {
return "\(htmlEscape(authorName)) made a \(callType) (\(formatDuration(Int(duration))))"
}
return "\(htmlEscape(authorName)) made a \(callType)"
default:
return nil
}
}
}
return nil
}
// MARK: - HTML Page Generation
private func htmlHeader(chatName: String) -> String {
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Exported Data</title>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<link href="css/style.css" rel="stylesheet"/>
<script src="js/script.js" type="text/javascript"></script>
</head>
<body onload="CheckLocation();">
<div class="page_wrap">
<div class="page_header">
<div class="content">
<div class="text bold">\(htmlEscape(chatName))</div>
</div>
</div>
<div class="page_body chat_page">
<div class="history">
"""
}
private func htmlFooter() -> String {
return """
</div>
</div>
</div>
</body>
</html>
"""
}
// MARK: - Export Engine
public struct SGChatExport {
public static func exportChat(
peerId: PeerId,
postbox: Postbox,
mediaBox: MediaBox
) -> Signal<SGChatExportProgress, NoError> {
return Signal { subscriber in
subscriber.putNext(.preparing)
let disposable = postbox.transaction { transaction -> Void in
let title = chatTitle(peerId: peerId, transaction: transaction)
// Collect all messages
var allMessages: [Message] = []
transaction.withAllMessages(peerId: peerId, namespace: 0) { message in
allMessages.append(message)
return true
}
allMessages.sort { $0.timestamp < $1.timestamp }
let totalMessages = allMessages.count
if totalMessages == 0 {
subscriber.putNext(.error("No messages to export"))
subscriber.putCompletion()
return
}
// Create export directory
let exportDirName = "ChatExport_\(title.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_"))"
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(exportDirName, isDirectory: true)
// Clean up previous export
try? FileManager.default.removeItem(at: tempDir)
do {
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("css"), withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("js"), withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("photos"), withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("video_files"), withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("voice_messages"), withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("round_video_messages"), withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("stickers"), withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: tempDir.appendingPathComponent("files"), withIntermediateDirectories: true)
} catch {
subscriber.putNext(.error("Failed to create export directory: \(error.localizedDescription)"))
subscriber.putCompletion()
return
}
// Write CSS and JS
do {
try sgChatExportCSS.write(to: tempDir.appendingPathComponent("css/style.css"), atomically: true, encoding: .utf8)
try sgChatExportJS.write(to: tempDir.appendingPathComponent("js/script.js"), atomically: true, encoding: .utf8)
} catch {
subscriber.putNext(.error("Failed to write assets: \(error.localizedDescription)"))
subscriber.putCompletion()
return
}
// Split into pages
let totalPages = max(1, (totalMessages + messagesPerPage - 1) / messagesPerPage)
var mediaFilesToCopy: [(source: String, destination: URL)] = []
for pageIndex in 0..<totalPages {
let startIdx = pageIndex * messagesPerPage
let endIdx = min(startIdx + messagesPerPage, totalMessages)
let pageMessages = Array(allMessages[startIdx..<endIdx])
let fileName = pageIndex == 0 ? "messages.html" : "messages\(pageIndex + 1).html"
let prevFileName = pageIndex == 1 ? "messages.html" : (pageIndex > 1 ? "messages\(pageIndex).html" : nil)
let nextFileName = pageIndex < totalPages - 1 ? "messages\(pageIndex + 2).html" : nil
var html = htmlHeader(chatName: title)
// Add "Previous messages" pagination link
if let prevFileName = prevFileName {
html += " <a class=\"pagination block_link\" href=\"\(prevFileName)\">Previous messages</a>\n\n"
}
var lastAuthorId: PeerId?
var lastDateStr: String?
var dateSeparatorId = -(pageIndex * 100 + 1)
for (msgIdx, message) in pageMessages.enumerated() {
let globalIdx = startIdx + msgIdx
subscriber.putNext(.exporting(current: globalIdx + 1, total: totalMessages))
let messageDate = Date(timeIntervalSince1970: Double(message.timestamp))
let currentDateStr = dateSeparatorFormatter.string(from: messageDate)
// Date separator
if currentDateStr != lastDateStr {
lastDateStr = currentDateStr
lastAuthorId = nil
html += """
<div class="message service" id="message\(dateSeparatorId)">
<div class="body details">\(currentDateStr)</div>
</div>
"""
dateSeparatorId -= 1
}
// Service message
if let serviceText = serviceMessageText(message) {
lastAuthorId = nil
html += """
<div class="message service" id="message\(message.id.id)">
<div class="body details">\(serviceText)</div>
</div>
"""
continue
}
// Regular message
let author = message.author
let authorId = author?.id
let isJoined = authorId == lastAuthorId && message.forwardInfo == nil
let joinedClass = isJoined ? " joined" : ""
let dateTitle = dateFormatter.string(from: messageDate) + " " + timeZoneSuffix()
let timeStr = timeFormatter.string(from: messageDate)
// Get media info
let mediaInfo = mediaInfoForMessage(message, mediaBox: mediaBox, messageDate: messageDate)
if let info = mediaInfo, let sourcePath = info.sourceResourcePath {
let destURL = tempDir
.appendingPathComponent(info.exportSubdir)
.appendingPathComponent(info.exportFileName)
mediaFilesToCopy.append((source: sourcePath, destination: destURL))
// For photos, also create a "thumb" copy
if info.exportSubdir == "photos" {
let thumbName = info.exportFileName.replacingOccurrences(of: ".jpg", with: "_thumb.jpg")
let thumbURL = tempDir
.appendingPathComponent(info.exportSubdir)
.appendingPathComponent(thumbName)
mediaFilesToCopy.append((source: sourcePath, destination: thumbURL))
}
}
let textContent = processMessageText(message.text)
let replyBlock = replyHTML(for: message)
let reactionsBlock = reactionsHTML(for: message)
html += " <div class=\"message default clearfix\(joinedClass)\" id=\"message\(message.id.id)\">\n"
// Userpic (only for non-joined messages)
if !isJoined {
let initial = peerInitial(author)
let colorIdx = userpicColorIndex(author)
html += """
<div class="pull_left userpic_wrap">
<div class="userpic userpic\(colorIdx)" style="width: 42px; height: 42px">
<div class="initials" style="line-height: 42px">\(htmlEscape(initial))</div>
</div>
</div>
"""
}
html += " <div class=\"body\">\n"
html += " <div class=\"pull_right date details\" title=\"\(dateTitle)\">\(timeStr)</div>\n"
// Author name (only for non-joined messages)
if !isJoined {
let authorName = htmlEscape(peerDisplayName(author))
html += " <div class=\"from_name\">\(authorName)</div>\n"
}
// Reply
if !replyBlock.isEmpty {
html += replyBlock + "\n"
}
// Forwarded message
if message.forwardInfo != nil {
let fwdBlock = forwardHTML(
message,
mediaHTML: mediaInfo?.htmlBlock ?? "",
textHTML: textContent
)
html += fwdBlock
} else {
// Media
if let mediaBlock = mediaInfo?.htmlBlock {
html += mediaBlock + "\n"
}
// Text
if !textContent.isEmpty {
html += " <div class=\"text\">\(textContent)</div>\n"
}
}
// Reactions
if !reactionsBlock.isEmpty {
html += reactionsBlock
}
html += " </div>\n"
html += " </div>\n\n"
lastAuthorId = authorId
}
// Add "Next messages" pagination link
if let nextFileName = nextFileName {
html += " <a class=\"pagination block_link\" href=\"\(nextFileName)\">Next messages</a>\n\n"
}
html += htmlFooter()
do {
let filePath = tempDir.appendingPathComponent(fileName)
try html.write(to: filePath, atomically: true, encoding: .utf8)
} catch {
subscriber.putNext(.error("Failed to write \(fileName): \(error.localizedDescription)"))
subscriber.putCompletion()
return
}
}
// Copy media files
for (idx, mediaCopy) in mediaFilesToCopy.enumerated() {
subscriber.putNext(.copyingMedia(current: idx + 1, total: mediaFilesToCopy.count))
do {
if !FileManager.default.fileExists(atPath: mediaCopy.destination.path) {
try FileManager.default.copyItem(atPath: mediaCopy.source, toPath: mediaCopy.destination.path)
}
} catch {
#if canImport(SGLogging)
SGLogger.shared.log("SGChatExport", "Failed to copy media: \(error.localizedDescription)")
#endif
}
}
subscriber.putNext(.done(tempDir))
subscriber.putCompletion()
}.start()
return ActionDisposable {
disposable.dispose()
}
}
}
}
@@ -0,0 +1,372 @@
import Foundation
let sgChatExportCSS: String = """
body {
margin: 0;
font: 12px/18px 'Open Sans',"Lucida Grande","Lucida Sans Unicode",Arial,Helvetica,Verdana,sans-serif;
}
strong {
font-weight: 700;
}
code, kbd, pre, samp {
font-family: Menlo,Monaco,Consolas,"Courier New",monospace;
}
code {
padding: 2px 4px;
font-size: 90%;
color: #c7254e;
background-color: #f9f2f4;
border-radius: 4px;
}
pre {
display: block;
margin: 0;
line-height: 1.42857143;
word-break: break-all;
word-wrap: break-word;
color: #333;
background-color: #f5f5f5;
border-radius: 4px;
overflow: auto;
padding: 3px;
border: 1px solid #eee;
max-height: none;
font-size: inherit;
}
.clearfix:after {
content: " ";
visibility: hidden;
display: block;
height: 0;
clear: both;
}
.pull_left {
float: left;
}
.pull_right {
float: right;
}
.page_wrap {
background-color: #ffffff;
color: #000000;
}
.page_wrap a {
color: #168acd;
text-decoration: none;
}
.page_wrap a:hover {
text-decoration: underline;
}
.page_header {
position: fixed;
z-index: 10;
background-color: #ffffff;
width: 100%;
border-bottom: 1px solid #e3e6e8;
}
.page_header .content {
width: 480px;
margin: 0 auto;
border-radius: 0 !important;
}
.bold {
color: #212121;
font-weight: 700;
}
.details {
color: #70777b;
}
.page_header .content .text {
padding: 24px 24px 22px 24px;
font-size: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page_body {
padding-top: 64px;
width: 480px;
margin: 0 auto;
}
.userpic {
display: block;
border-radius: 50%;
overflow: hidden;
}
.userpic .initials {
display: block;
color: #fff;
text-align: center;
text-transform: uppercase;
user-select: none;
}
.userpic1 { background-color: #ff5555; }
.userpic2 { background-color: #64bf47; }
.userpic3 { background-color: #ffab00; }
.userpic4 { background-color: #4f9cd9; }
.userpic5 { background-color: #9884e8; }
.userpic6 { background-color: #e671a5; }
.userpic7 { background-color: #47bcd1; }
.userpic8 { background-color: #ff8c44; }
.history {
padding: 16px 0;
}
.message {
margin: 0 -10px;
transition: background-color 2.0s ease;
}
div.selected {
background-color: rgba(242,246,250,255);
transition: background-color 0.5s ease;
}
.service {
padding: 10px 24px;
}
.service .body {
text-align: center;
}
.message .userpic .initials {
font-size: 16px;
}
.default {
padding: 10px;
}
.default.joined {
margin-top: -10px;
}
.default .from_name {
color: #3892db;
font-weight: 700;
padding-bottom: 5px;
}
.default .from_name .details {
font-weight: normal;
}
.default .body {
margin-left: 60px;
}
.default .text {
word-wrap: break-word;
line-height: 150%;
unicode-bidi: plaintext;
text-align: start;
}
.default .reply_to,
.default .media_wrap {
padding-bottom: 5px;
}
.default .media {
margin: 0 -10px;
padding: 5px 10px;
}
.default .media .fill,
.default .media .thumb {
width: 48px;
height: 48px;
border-radius: 50%;
}
.default .media .fill {
background-repeat: no-repeat;
background-position: 12px 12px;
background-size: 24px 24px;
}
.default .media .title {
padding-top: 4px;
font-size: 14px;
}
.default .media .description {
color: #000000;
padding-top: 4px;
font-size: 13px;
}
.default .media .status {
padding-top: 4px;
font-size: 13px;
}
.default .video_file_wrap {
position: relative;
}
.default .video_file,
.default .photo,
.default .sticker {
display: block;
}
.video_duration {
background: rgba(0, 0, 0, .4);
padding: 0px 5px;
position: absolute;
z-index: 2;
border-radius: 2px;
right: 3px;
bottom: 3px;
color: #ffffff;
font-size: 11px;
}
.video_play_bg {
background: rgba(0, 0, 0, .4);
width: 40px;
height: 40px;
line-height: 0;
position: absolute;
z-index: 2;
border-radius: 50%;
overflow: hidden;
margin: -20px auto 0 -20px;
top: 50%;
left: 50%;
pointer-events: none;
}
.video_play {
position: absolute;
display: inline-block;
top: 50%;
left: 50%;
margin-left: -5px;
margin-top: -9px;
z-index: 1;
width: 0;
height: 0;
border-style: solid;
border-width: 9px 0 9px 14px;
border-color: transparent transparent transparent #fff;
}
.pagination {
text-align: center;
padding: 20px;
font-size: 16px;
}
.toast_container {
position: fixed;
left: 50%;
top: 50%;
opacity: 0;
transition: opacity 3.0s ease;
}
.toast_body {
margin: 0 -50%;
float: left;
border-radius: 15px;
padding: 10px 20px;
background: rgba(0, 0, 0, 0.7);
color: #ffffff;
}
div.toast_shown {
opacity: 1;
transition: opacity 0.4s ease;
}
.media_voice_message .fill { background-color: #4f9cd9; }
.media_file .fill { background-color: #ff5555; }
.media_photo .fill { background-color: #64bf47; }
.media_video .fill { background-color: #47bcd1; }
.media_contact .fill { background-color: #ff8c44; }
.media_location .fill { background-color: #47bcd1; }
.spoiler {
background: #e8e8e8;
}
.spoiler.hidden {
background: #a9a9a9;
cursor: pointer;
border-radius: 3px;
}
.spoiler.hidden span {
opacity: 0;
user-select: none;
}
.reactions {
margin: 5px 0;
}
.reactions .reaction {
display: inline-flex;
height: 20px;
border-radius: 15px;
background-color: #e8f5fc;
color: #168acd;
font-weight: bold;
margin-bottom: 5px;
}
.reactions .reaction.active {
background-color: #40a6e2;
color: #fff;
}
.reactions .reaction .emoji {
line-height: 20px;
margin: 0 5px;
font-size: 15px;
}
.reactions .reaction .userpic:not(:first-child) {
margin-left: -8px;
}
.reactions .reaction .userpic {
display: inline-block;
}
.reactions .reaction .userpic .initials {
font-size: 8px;
}
.reactions .reaction .count {
margin-right: 8px;
line-height: 20px;
}
@media (prefers-color-scheme: dark) {
html, body {
background-color: #1a2026;
margin: 0;
padding: 0;
}
.page_wrap {
background-color: #1a2026;
color: #ffffff;
min-height: 100vh;
}
.page_wrap a {
color: #4db8ff;
}
.page_header {
background-color: #1a2026;
border-bottom: 1px solid #2c333d;
}
.bold {
color: #ffffff;
}
.details {
color: #91979e;
}
.page_body {
background-color: #1a2026;
}
code {
color: #ff8aac;
background-color: #2c333d;
}
pre {
color: #ffffff;
background-color: #2c333d;
border: 1px solid #323a45;
}
.message {
color: #ffffff;
}
div.selected {
background-color: #323a45;
}
.default .from_name {
color: #4db8ff;
}
.default .media .description {
color: #ffffff;
}
.spoiler {
background: #323a45;
}
.spoiler.hidden {
background: #61c0ff;
}
.reactions .reaction {
background-color: #2c333d;
color: #4db8ff;
}
.reactions .reaction.active {
background-color: #4db8ff;
color: #1a2026;
}
}
"""
@@ -0,0 +1,150 @@
import Foundation
let sgChatExportJS: String = """
"use strict";
window.AllowBackFromHistory = false;
function CheckLocation() {
var start = "#go_to_message";
var hash = location.hash;
if (hash.substr(0, start.length) == start) {
var messageId = parseInt(hash.substr(start.length));
if (messageId) {
GoToMessage(messageId);
}
} else if (hash == "#allow_back") {
window.AllowBackFromHistory = true;
}
}
function ShowToast(text) {
var container = document.createElement("div");
container.className = "toast_container";
var inner = container.appendChild(document.createElement("div"));
inner.className = "toast_body";
inner.appendChild(document.createTextNode(text));
var appended = document.body.appendChild(container);
setTimeout(function () {
AddClass(appended, "toast_shown");
setTimeout(function () {
RemoveClass(appended, "toast_shown");
setTimeout(function () {
document.body.removeChild(appended);
}, 3000);
}, 3000);
}, 0);
}
function ShowSpoiler(target) {
if (target.classList.contains("hidden")) {
target.classList.toggle("hidden");
}
}
function AddClass(element, name) {
var current = element.className;
var expression = new RegExp('(^|\\\\s)' + name + '(\\\\s|$)', 'g');
if (expression.test(current)) {
return;
}
element.className = current + ' ' + name;
}
function RemoveClass(element, name) {
var current = element.className;
var expression = new RegExp('(^|\\\\s)' + name + '(\\\\s|$)', '');
var match = expression.exec(current);
while ((match = expression.exec(current)) != null) {
if (match[1].length > 0 && match[2].length > 0) {
current = current.substr(0, match.index + match[1].length)
+ current.substr(match.index + match[0].length);
} else {
current = current.substr(0, match.index)
+ current.substr(match.index + match[0].length);
}
}
element.className = current;
}
function ScrollHeight() {
if ("innerHeight" in window) {
return window.innerHeight;
} else if (document.documentElement) {
return document.documentElement.clientHeight;
}
return document.body.clientHeight;
}
function ScrollTo(top, callback) {
var html = document.documentElement;
var current = html.scrollTop;
var delta = top - current;
var finish = function () {
html.scrollTop = top;
if (callback) {
callback();
}
};
if (!window.performance.now || delta == 0) {
finish();
return;
}
var max = 300;
if (delta < -max) {
current = top + max;
delta = -max;
} else if (delta > max) {
current = top - max;
delta = max;
}
var duration = 150;
var interval = 7;
var time = window.performance.now();
var animate = function () {
var now = window.performance.now();
if (now >= time + duration) {
finish();
return;
}
var dt = (now - time) / duration;
html.scrollTop = Math.round(current + delta * dt * dt);
setTimeout(animate, interval);
};
setTimeout(animate, interval);
}
function ScrollToElement(element, callback) {
var header = document.getElementsByClassName("page_header")[0];
var headerHeight = header.offsetHeight;
var html = document.documentElement;
var scrollHeight = ScrollHeight();
var available = scrollHeight - headerHeight;
var padding = 10;
var top = element.offsetTop;
var height = element.offsetHeight;
var desired = top
- Math.max((available - height) / 2, padding)
- headerHeight;
var scrollTopMax = html.offsetHeight - scrollHeight;
ScrollTo(Math.min(desired, scrollTopMax), callback);
}
function GoToMessage(messageId) {
var element = document.getElementById("message" + messageId);
if (element) {
var hash = "#go_to_message" + messageId;
if (location.hash != hash) {
location.hash = hash;
}
ScrollToElement(element, function () {
AddClass(element, "selected");
setTimeout(function () {
RemoveClass(element, "selected");
}, 1000);
});
} else {
ShowToast("This message was not exported. Maybe it was deleted.");
}
return false;
}
"""
+17
View File
@@ -0,0 +1,17 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGDeletedMessages",
module_name = "SGDeletedMessages",
srcs = glob(["Sources/**/*.swift"]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Postbox:Postbox",
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGLogging:SGLogging",
],
visibility = ["//visibility:public"],
)
@@ -0,0 +1,148 @@
import Foundation
import Postbox
public final class SGDeletedMessageAttribute: MessageAttribute, Equatable {
public var isDeleted: Bool
public var originalText: String?
/// Full edit history: [original, edit1, edit2, ...]. First is original, last is previous before current.
public var editHistory: [String]
// MARK: Swiftgram
// For SavedDeleted snapshots, keep a reference to the original message id.
public var originalNamespace: Int32?
public var originalId: Int32?
public init(isDeleted: Bool = false, originalText: String? = nil, editHistory: [String] = [], originalNamespace: Int32? = nil, originalId: Int32? = nil) {
self.isDeleted = isDeleted
self.originalText = originalText
self.editHistory = editHistory
self.originalNamespace = originalNamespace
self.originalId = originalId
}
public init(decoder: PostboxDecoder) {
self.isDeleted = decoder.decodeInt32ForKey("d", orElse: 0) != 0
self.originalText = decoder.decodeOptionalStringForKey("ot")
self.editHistory = decoder.decodeOptionalStringArrayForKey("eh") ?? []
self.originalNamespace = decoder.decodeOptionalInt32ForKey("on")
self.originalId = decoder.decodeOptionalInt32ForKey("oi")
}
public func encode(_ encoder: PostboxEncoder) {
encoder.encodeInt32(self.isDeleted ? 1 : 0, forKey: "d")
if let originalText = self.originalText {
encoder.encodeString(originalText, forKey: "ot")
}
if !editHistory.isEmpty {
encoder.encodeStringArray(editHistory, forKey: "eh")
}
if let originalNamespace = self.originalNamespace {
encoder.encodeInt32(originalNamespace, forKey: "on")
}
if let originalId = self.originalId {
encoder.encodeInt32(originalId, forKey: "oi")
}
}
public static func ==(lhs: SGDeletedMessageAttribute, rhs: SGDeletedMessageAttribute) -> Bool {
return lhs.isDeleted == rhs.isDeleted && lhs.originalText == rhs.originalText && lhs.editHistory == rhs.editHistory && lhs.originalNamespace == rhs.originalNamespace && lhs.originalId == rhs.originalId
}
/// All text versions in chronological order: [original, edit1, edit2, ..., current].
public func allEditVersions(currentText: String) -> [String] {
var versions: [String] = []
if let ot = originalText, !ot.isEmpty {
versions.append(ot)
}
for h in editHistory where !h.isEmpty && h != versions.last {
versions.append(h)
}
if !currentText.isEmpty && currentText != versions.last {
versions.append(currentText)
}
return versions
}
}
// MARK: Swiftgram - Extension for Message (like Nicegram)
public extension Message {
var sgDeletedAttribute: SGDeletedMessageAttribute {
for attribute in self.attributes {
if let deletedAttribute = attribute as? SGDeletedMessageAttribute {
return deletedAttribute
}
}
return SGDeletedMessageAttribute()
}
}
// MARK: Swiftgram - Extension for Transaction (like Nicegram)
public extension Transaction {
func updateSGDeletedAttribute(messageId: MessageId, _ block: (inout SGDeletedMessageAttribute) -> Void) {
self.updateMessage(messageId) { message in
var attributes = message.attributes
attributes.updateSGDeletedAttribute(block)
let storeForwardInfo = message.forwardInfo.flatMap(StoreMessageForwardInfo.init)
return .update(StoreMessage(
id: message.id,
customStableId: nil,
globallyUniqueId: message.globallyUniqueId,
groupingKey: message.groupingKey,
threadId: message.threadId,
timestamp: message.timestamp,
flags: StoreMessageFlags(message.flags),
tags: message.tags,
globalTags: message.globalTags,
localTags: message.localTags,
forwardInfo: storeForwardInfo,
authorId: message.author?.id,
text: message.text,
attributes: attributes,
media: message.media
))
}
}
}
// MARK: Swiftgram - Extension for StoreMessage (like Nicegram)
public extension StoreMessage {
func updatingSGDeletedAttributeOnEdit(previousMessage: Message) -> StoreMessage {
let newAttr = self.attributes.compactMap { $0 as? SGDeletedMessageAttribute }.first
let attr = newAttr ?? previousMessage.sgDeletedAttribute
if attr.originalText == nil {
attr.originalText = previousMessage.text
}
// Append previous text to full edit history (skip if same as last)
let prev = previousMessage.text
if !prev.isEmpty {
let last = attr.editHistory.last ?? attr.originalText
if prev != last {
attr.editHistory.append(prev)
}
}
var attributes = self.attributes
attributes.updateSGDeletedAttribute {
$0 = attr
}
return self.withUpdatedAttributes(attributes)
}
}
// MARK: Swiftgram - Extension for Array<MessageAttribute> (like Nicegram)
private extension Array<MessageAttribute> {
mutating func updateSGDeletedAttribute(_ block: (inout SGDeletedMessageAttribute) -> Void) {
for (index, attribute) in self.enumerated() {
if var deletedAttribute = attribute as? SGDeletedMessageAttribute {
block(&deletedAttribute)
self[index] = deletedAttribute
return
}
}
var deletedAttribute = SGDeletedMessageAttribute()
block(&deletedAttribute)
self.append(deletedAttribute)
}
}
+246
View File
@@ -0,0 +1,246 @@
import Foundation
import Postbox
import SwiftSignalKit
import SGSimpleSettings
#if canImport(SGLogging)
import SGLogging
#endif
// Local constants to avoid circular dependency with TelegramCore (SyncCore_Namespaces).
// Namespaces.Message.Cloud = 0
private let messageNamespaceCloud: Int32 = 0
// Namespaces.Message.SavedDeleted = 1338
private let messageNamespaceSavedDeleted: Int32 = 1338
public struct SGDeletedMessages {
public static var showDeletedMessages: Bool {
get {
return SGSimpleSettings.shared.showDeletedMessages
}
set {
SGSimpleSettings.shared.showDeletedMessages = newValue
}
}
private static func savedDeletedId(for originalId: MessageId) -> MessageId {
return MessageId(peerId: originalId.peerId, namespace: messageNamespaceSavedDeleted, id: originalId.id)
}
/// AyuGram-style: create a local SavedDeleted snapshot (separate namespace) and return `true` if saved.
private static func saveSnapshotIfPossible(
originalId: MessageId,
transaction: Transaction,
shouldSave: ((MessageId, Message) -> Bool)?,
transformAttributes: ((Message, inout [MessageAttribute]) -> Void)?,
transformMedia: ((Message, [Media]) -> [Media])?
) -> Bool {
// If we're deleting an already-saved snapshot, don't re-save it.
if originalId.namespace == messageNamespaceSavedDeleted {
return false
}
guard let message = transaction.getMessage(originalId) else {
// No local copy -> can't save (AyuGram behavior).
return false
}
if let shouldSave, !shouldSave(originalId, message) {
return false
}
let snapshotId = savedDeletedId(for: originalId)
if transaction.messageExists(id: snapshotId) {
return true
}
let storeForwardInfo = message.forwardInfo.flatMap(StoreMessageForwardInfo.init)
var attributes = message.attributes
var hasDeletedAttribute = false
for attribute in attributes {
if let deletedAttribute = attribute as? SGDeletedMessageAttribute {
deletedAttribute.isDeleted = true
if deletedAttribute.originalText == nil {
deletedAttribute.originalText = message.text
}
deletedAttribute.originalNamespace = originalId.namespace
deletedAttribute.originalId = originalId.id
hasDeletedAttribute = true
break
}
}
if !hasDeletedAttribute {
attributes.append(SGDeletedMessageAttribute(isDeleted: true, originalText: message.text, originalNamespace: originalId.namespace, originalId: originalId.id))
}
transformAttributes?(message, &attributes)
let media: [Media]
if let transformMedia {
media = transformMedia(message, message.media)
} else {
media = message.media
}
// Important: this is a local-only snapshot, so we don't keep a globallyUniqueId
// (to avoid collisions with the original message).
let storeMessage = StoreMessage(
id: snapshotId,
customStableId: nil,
globallyUniqueId: nil,
groupingKey: message.groupingKey,
threadId: message.threadId,
timestamp: message.timestamp,
flags: StoreMessageFlags(message.flags),
tags: message.tags,
globalTags: message.globalTags,
localTags: message.localTags,
forwardInfo: storeForwardInfo,
authorId: message.author?.id,
text: message.text,
attributes: attributes,
media: media
)
let _ = transaction.addMessages([storeMessage], location: .UpperHistoryBlock)
#if canImport(SGLogging)
SGLogger.shared.log("SGDeletedMessages", "saveSnapshotIfPossible: saved snapshot \(snapshotId) for original \(originalId)")
#endif
return true
}
/// AyuGram-style: save snapshots (when possible).
/// Returns the set of message ids for which a snapshot exists (created or already present).
public static func saveSnapshots(
ids: [MessageId],
transaction: Transaction,
shouldSave: ((MessageId, Message) -> Bool)? = nil,
transformAttributes: ((Message, inout [MessageAttribute]) -> Void)? = nil,
transformMedia: ((Message, [Media]) -> [Media])? = nil
) -> Set<MessageId> {
guard showDeletedMessages, !ids.isEmpty else { return Set() }
var result = Set<MessageId>()
result.reserveCapacity(ids.count)
for id in ids {
if saveSnapshotIfPossible(originalId: id, transaction: transaction, shouldSave: shouldSave, transformAttributes: transformAttributes, transformMedia: transformMedia) {
result.insert(id)
}
}
return result
}
/// AyuGram-style: for delete-by-global-id pipelines, save snapshots for locally-present messages.
public static func saveSnapshotsForGlobalIds(
_ globalIds: [Int32],
transaction: Transaction,
shouldSave: ((MessageId, Message) -> Bool)? = nil,
transformAttributes: ((Message, inout [MessageAttribute]) -> Void)? = nil,
transformMedia: ((Message, [Media]) -> [Media])? = nil
) {
guard showDeletedMessages else { return }
for globalId in globalIds {
if let id = transaction.messageIdsForGlobalIds([globalId]).first {
_ = saveSnapshotIfPossible(originalId: id, transaction: transaction, shouldSave: shouldSave, transformAttributes: transformAttributes, transformMedia: transformMedia)
}
}
}
/// AyuGram-style: save snapshots (when possible) and return ids to physically delete.
/// If the id itself is already a SavedDeleted snapshot, it will be deleted (no resave).
public static func saveSnapshotsAndReturnIdsToDelete(ids: [MessageId], transaction: Transaction) -> [MessageId] {
_ = saveSnapshots(ids: ids, transaction: transaction, shouldSave: nil, transformAttributes: nil, transformMedia: nil)
return ids
}
/// Check if message is marked as deleted (using extension like Nicegram)
public static func isMessageDeleted(_ message: Message) -> Bool {
return message.sgDeletedAttribute.isDeleted
}
/// Get original text from message attribute (for edit history, using extension like Nicegram)
public static func getOriginalText(_ message: Message) -> String? {
return message.sgDeletedAttribute.originalText
}
/// Returns the combined on-disk size (in bytes) of the saved-deleted-attachments folder.
public static func storageSizeBytes(mediaBoxBasePath: String) -> Int64 {
let attachmentsPath = mediaBoxBasePath + "/saved-deleted-attachments"
guard let enumerator = FileManager.default.enumerator(
at: URL(fileURLWithPath: attachmentsPath),
includingPropertiesForKeys: [.fileSizeKey],
options: [.skipsHiddenFiles]
) else { return 0 }
var total: Int64 = 0
for case let url as URL in enumerator {
total += Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
}
return total
}
/// Fetch all saved deleted messages grouped by peer.
public static func getAllSavedDeletedMessages(
postbox: Postbox
) -> Signal<[(peer: Peer?, peerId: PeerId, messages: [Message])], NoError> {
return postbox.transaction { transaction -> [(peer: Peer?, peerId: PeerId, messages: [Message])] in
var result: [(peer: Peer?, peerId: PeerId, messages: [Message])] = []
let allPeerIds = transaction.chatListGetAllPeerIds()
for peerId in allPeerIds {
var messages: [Message] = []
transaction.scanMessageAttributes(peerId: peerId, namespace: messageNamespaceSavedDeleted, limit: Int.max) { messageId, _ in
if let message = transaction.getMessage(messageId) {
messages.append(message)
}
return true
}
if !messages.isEmpty {
messages.sort { $0.timestamp > $1.timestamp }
let peer = transaction.getPeer(peerId)
result.append((peer: peer, peerId: peerId, messages: messages))
}
}
result.sort { ($0.messages.first?.timestamp ?? 0) > ($1.messages.first?.timestamp ?? 0) }
return result
}
}
/// Delete specific saved deleted messages by their IDs.
public static func deleteSavedDeletedMessages(
ids: [MessageId],
postbox: Postbox
) -> Signal<Void, NoError> {
return postbox.transaction { transaction -> Void in
if !ids.isEmpty {
transaction.deleteMessages(ids, forEachMedia: { _ in })
}
}
}
/// Clear all saved deleted messages (actually delete them). Returns the number of deleted messages.
public static func clearAllDeletedMessages(
postbox: Postbox
) -> Signal<Int, NoError> {
return postbox.transaction { transaction -> Int in
// Remove saved attachment copies (AyuGram-style "Saved Attachments").
let attachmentsPath = postbox.mediaBox.basePath + "/saved-deleted-attachments"
let _ = try? FileManager.default.removeItem(atPath: attachmentsPath)
let _ = try? FileManager.default.createDirectory(atPath: attachmentsPath, withIntermediateDirectories: true, attributes: nil)
// All messages in the SavedDeleted namespace (1338) are snapshots no attribute check needed.
var messageIdsToDelete: [MessageId] = []
let allPeerIds = transaction.chatListGetAllPeerIds()
for peerId in allPeerIds {
transaction.scanMessageAttributes(peerId: peerId, namespace: messageNamespaceSavedDeleted, limit: Int.max) { messageId, _ in
messageIdsToDelete.append(messageId)
return true
}
}
let count = messageIdsToDelete.count
if !messageIdsToDelete.isEmpty {
transaction.deleteMessages(messageIdsToDelete, forEachMedia: { _ in })
}
return count
}
}
}
+22
View File
@@ -0,0 +1,22 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGFakeLocation",
module_name = "SGFakeLocation",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGStrings:SGStrings",
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/Display:Display",
"//submodules/TelegramPresentationData:TelegramPresentationData",
],
visibility = [
"//visibility:public",
],
)
+321
View File
@@ -0,0 +1,321 @@
import Foundation
import UIKit
import CoreLocation
#if canImport(SGSimpleSettings)
import SGSimpleSettings
#endif
public final class FakeLocationManager: NSObject {
public static let shared = FakeLocationManager()
private var locationManagers: NSHashTable<CLLocationManager> = NSHashTable.weakObjects()
private var periodicUpdateTimer: Foundation.Timer?
private let timerInterval: TimeInterval = 20.0
private override init() {
super.init()
setupAppLifecycleObservers()
}
private func setupAppLifecycleObservers() {
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}
@objc private func appDidBecomeActive() {
startPeriodicUpdates()
}
@objc private func appDidEnterBackground() {
stopPeriodicUpdates()
}
private func startPeriodicUpdates() {
if periodicUpdateTimer != nil {
return
}
#if canImport(SGSimpleSettings)
guard SGSimpleSettings.shared.fakeLocationEnabled else {
return
}
sendFakeLocationToAllManagers()
periodicUpdateTimer = Foundation.Timer.scheduledTimer(
withTimeInterval: timerInterval,
repeats: true
) { [weak self] _ in
self?.sendFakeLocationToAllManagers()
}
if let timer = periodicUpdateTimer {
RunLoop.current.add(timer, forMode: .common)
}
#endif
}
private func stopPeriodicUpdates() {
periodicUpdateTimer?.invalidate()
periodicUpdateTimer = nil
}
private func sendFakeLocationToAllManagers() {
#if canImport(SGSimpleSettings)
guard SGSimpleSettings.shared.fakeLocationEnabled else {
stopPeriodicUpdates()
return
}
let latitude = SGSimpleSettings.shared.fakeLatitude
let longitude = SGSimpleSettings.shared.fakeLongitude
guard latitude != 0.0 && longitude != 0.0 else {
return
}
for manager in locationManagers.allObjects {
sendFakeLocationToManager(manager)
}
#endif
}
func addLocationManager(_ manager: CLLocationManager) {
locationManagers.add(manager)
}
func sendFakeLocationToManager(_ manager: CLLocationManager) {
#if canImport(SGSimpleSettings)
guard SGSimpleSettings.shared.fakeLocationEnabled else { return }
let latitude = SGSimpleSettings.shared.fakeLatitude
let longitude = SGSimpleSettings.shared.fakeLongitude
guard latitude != 0.0 && longitude != 0.0 else { return }
let fakeLocation = CLLocation(
coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude),
altitude: 0,
horizontalAccuracy: 10,
verticalAccuracy: 10,
timestamp: Date()
)
let fakeLocations = [fakeLocation]
if let delegate = manager.delegate {
delegate.locationManager?(manager, didUpdateLocations: fakeLocations)
}
#endif
}
}
extension CLLocationManager {
private static var swizzled = false
private static var originalStartUpdatingLocationIMP: IMP?
private static var originalRequestLocationIMP: IMP?
private static var originalLocationGetterIMP: IMP?
private static var originalSetDelegateIMP: IMP?
private static var originalRequestAlwaysAuthorizationIMP: IMP?
private static var originalRequestWhenInUseAuthorizationIMP: IMP?
private static var originalAuthorizationStatusGetterIMP: IMP?
private static var originalStaticAuthorizationStatusIMP: IMP?
public static func swizzleLocationMethods() {
guard !swizzled else { return }
swizzled = true
let startUpdatingLocationSelector = #selector(CLLocationManager.startUpdatingLocation)
let requestLocationSelector = #selector(CLLocationManager.requestLocation)
let locationGetterSelector = #selector(getter: CLLocationManager.location)
let setDelegateSelector = #selector(setter: CLLocationManager.delegate)
let requestAlwaysAuthorizationSelector = #selector(CLLocationManager.requestAlwaysAuthorization)
let requestWhenInUseAuthorizationSelector = #selector(CLLocationManager.requestWhenInUseAuthorization)
let authorizationStatusSelector = NSSelectorFromString("authorizationStatus")
let staticAuthorizationStatusSelector = #selector(CLLocationManager.authorizationStatus)
if let method = class_getInstanceMethod(CLLocationManager.self, startUpdatingLocationSelector),
let swizzledMethod = class_getInstanceMethod(CLLocationManager.self, #selector(CLLocationManager.swizzled_startUpdatingLocation)) {
originalStartUpdatingLocationIMP = method_getImplementation(method)
method_exchangeImplementations(method, swizzledMethod)
}
if #available(iOS 9.0, *) {
if let method = class_getInstanceMethod(CLLocationManager.self, requestLocationSelector),
let swizzledMethod = class_getInstanceMethod(CLLocationManager.self, #selector(CLLocationManager.swizzled_requestLocation)) {
originalRequestLocationIMP = method_getImplementation(method)
method_exchangeImplementations(method, swizzledMethod)
}
}
if let method = class_getInstanceMethod(CLLocationManager.self, locationGetterSelector),
let swizzledMethod = class_getInstanceMethod(CLLocationManager.self, #selector(CLLocationManager.swizzled_location)) {
originalLocationGetterIMP = method_getImplementation(method)
method_exchangeImplementations(method, swizzledMethod)
}
if let method = class_getInstanceMethod(CLLocationManager.self, setDelegateSelector),
let swizzledMethod = class_getInstanceMethod(CLLocationManager.self, #selector(CLLocationManager.swizzled_setDelegate(_:))) {
originalSetDelegateIMP = method_getImplementation(method)
method_exchangeImplementations(method, swizzledMethod)
}
if let method = class_getInstanceMethod(CLLocationManager.self, requestAlwaysAuthorizationSelector),
let swizzledMethod = class_getInstanceMethod(CLLocationManager.self, #selector(CLLocationManager.swizzled_requestAlwaysAuthorization)) {
originalRequestAlwaysAuthorizationIMP = method_getImplementation(method)
method_exchangeImplementations(method, swizzledMethod)
}
if let method = class_getInstanceMethod(CLLocationManager.self, requestWhenInUseAuthorizationSelector),
let swizzledMethod = class_getInstanceMethod(CLLocationManager.self, #selector(CLLocationManager.swizzled_requestWhenInUseAuthorization)) {
originalRequestWhenInUseAuthorizationIMP = method_getImplementation(method)
method_exchangeImplementations(method, swizzledMethod)
}
if #available(iOS 14.0, *) {
if let method = class_getInstanceMethod(CLLocationManager.self, authorizationStatusSelector),
let swizzledMethod = class_getInstanceMethod(CLLocationManager.self, #selector(CLLocationManager.swizzled_authorizationStatus)) {
originalAuthorizationStatusGetterIMP = method_getImplementation(method)
method_exchangeImplementations(method, swizzledMethod)
}
}
if let method = class_getClassMethod(CLLocationManager.self, staticAuthorizationStatusSelector),
let swizzledMethod = class_getClassMethod(CLLocationManager.self, #selector(CLLocationManager.swizzled_staticAuthorizationStatus)) {
originalStaticAuthorizationStatusIMP = method_getImplementation(method)
method_exchangeImplementations(method, swizzledMethod)
}
}
@objc private func swizzled_setDelegate(_ delegate: CLLocationManagerDelegate?) {
if let originalIMP = CLLocationManager.originalSetDelegateIMP {
typealias MethodType = @convention(c) (AnyObject, Selector, CLLocationManagerDelegate?) -> Void
let methodFunc = unsafeBitCast(originalIMP, to: MethodType.self)
methodFunc(self, #selector(setter: CLLocationManager.delegate), delegate)
}
FakeLocationManager.shared.addLocationManager(self)
#if canImport(SGSimpleSettings)
if SGSimpleSettings.shared.fakeLocationEnabled {
let latitude = SGSimpleSettings.shared.fakeLatitude
let longitude = SGSimpleSettings.shared.fakeLongitude
if latitude != 0.0 && longitude != 0.0 {
self.stopUpdatingLocation()
FakeLocationManager.shared.sendFakeLocationToManager(self)
}
}
#endif
}
@objc private func swizzled_startUpdatingLocation() {
FakeLocationManager.shared.addLocationManager(self)
#if canImport(SGSimpleSettings)
if SGSimpleSettings.shared.fakeLocationEnabled {
let latitude = SGSimpleSettings.shared.fakeLatitude
let longitude = SGSimpleSettings.shared.fakeLongitude
if latitude != 0.0 && longitude != 0.0 {
FakeLocationManager.shared.sendFakeLocationToManager(self)
return
}
}
#endif
if let originalIMP = CLLocationManager.originalStartUpdatingLocationIMP {
typealias MethodType = @convention(c) (AnyObject, Selector) -> Void
let methodFunc = unsafeBitCast(originalIMP, to: MethodType.self)
methodFunc(self, #selector(startUpdatingLocation))
}
}
@available(iOS 9.0, *)
@objc private func swizzled_requestLocation() {
#if canImport(SGSimpleSettings)
if SGSimpleSettings.shared.fakeLocationEnabled {
let latitude = SGSimpleSettings.shared.fakeLatitude
let longitude = SGSimpleSettings.shared.fakeLongitude
if latitude != 0.0 && longitude != 0.0 {
FakeLocationManager.shared.sendFakeLocationToManager(self)
return
}
}
#endif
if let originalIMP = CLLocationManager.originalRequestLocationIMP {
typealias MethodType = @convention(c) (AnyObject, Selector) -> Void
let methodFunc = unsafeBitCast(originalIMP, to: MethodType.self)
methodFunc(self, #selector(requestLocation))
}
}
@objc private func swizzled_location() -> CLLocation? {
#if canImport(SGSimpleSettings)
if SGSimpleSettings.shared.fakeLocationEnabled {
let latitude = SGSimpleSettings.shared.fakeLatitude
let longitude = SGSimpleSettings.shared.fakeLongitude
if latitude != 0.0 && longitude != 0.0 {
return CLLocation(
coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude),
altitude: 0,
horizontalAccuracy: 10,
verticalAccuracy: 10,
timestamp: Date()
)
}
}
#endif
if let originalIMP = CLLocationManager.originalLocationGetterIMP {
typealias MethodType = @convention(c) (AnyObject, Selector) -> CLLocation?
let methodFunc = unsafeBitCast(originalIMP, to: MethodType.self)
return methodFunc(self, #selector(getter: CLLocationManager.location))
}
return nil
}
@objc private func swizzled_requestAlwaysAuthorization() {
if let originalIMP = CLLocationManager.originalRequestAlwaysAuthorizationIMP {
typealias MethodType = @convention(c) (AnyObject, Selector) -> Void
let methodFunc = unsafeBitCast(originalIMP, to: MethodType.self)
methodFunc(self, #selector(requestAlwaysAuthorization))
}
}
@objc private func swizzled_requestWhenInUseAuthorization() {
if let originalIMP = CLLocationManager.originalRequestWhenInUseAuthorizationIMP {
typealias MethodType = @convention(c) (AnyObject, Selector) -> Void
let methodFunc = unsafeBitCast(originalIMP, to: MethodType.self)
methodFunc(self, #selector(requestWhenInUseAuthorization))
}
}
@available(iOS 14.0, *)
@objc private func swizzled_authorizationStatus() -> CLAuthorizationStatus {
if let originalIMP = CLLocationManager.originalAuthorizationStatusGetterIMP {
typealias MethodType = @convention(c) (AnyObject, Selector) -> CLAuthorizationStatus
let methodFunc = unsafeBitCast(originalIMP, to: MethodType.self)
return methodFunc(self, NSSelectorFromString("authorizationStatus"))
}
return .notDetermined
}
@objc private static func swizzled_staticAuthorizationStatus() -> CLAuthorizationStatus {
if let originalIMP = CLLocationManager.originalStaticAuthorizationStatusIMP {
typealias MethodType = @convention(c) (AnyClass, Selector) -> CLAuthorizationStatus
let methodFunc = unsafeBitCast(originalIMP, to: MethodType.self)
return methodFunc(CLLocationManager.self, #selector(CLLocationManager.authorizationStatus))
}
return .notDetermined
}
}
@@ -0,0 +1,201 @@
import Foundation
import UIKit
import MapKit
import CoreLocation
#if canImport(SGSimpleSettings)
import SGSimpleSettings
#endif
#if canImport(SGStrings)
import SGStrings
#endif
import Display
import TelegramPresentationData
public final class FakeLocationPickerController: ViewController {
private let presentationData: PresentationData
private var mapView: MKMapView!
private var currentPin: MKPointAnnotation?
private var saveButton: UIButton!
private var mapTypeControl: UISegmentedControl!
private var onSave: (() -> Void)?
public init(presentationData: PresentationData, onSave: (() -> Void)? = nil) {
self.presentationData = presentationData
self.onSave = onSave
super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: presentationData))
let lang = presentationData.strings.baseLanguageCode
self.title = (lang == "ru" ? "Выбор местоположения" : "Pick Location")
let backItem = UIBarButtonItem(backButtonAppearanceWithTitle: presentationData.strings.Common_Back, target: self, action: #selector(self.backPressed))
self.navigationItem.leftBarButtonItem = backItem
}
@objc private func backPressed() {
navigateBack()
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func loadView() {
super.loadView()
view.backgroundColor = presentationData.theme.list.plainBackgroundColor
mapView = MKMapView()
mapView.delegate = self
mapView.showsUserLocation = false
mapView.translatesAutoresizingMaskIntoConstraints = false
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:)))
mapView.addGestureRecognizer(tapGesture)
let lang = presentationData.strings.baseLanguageCode
let mapTypeItems = [
(lang == "ru" ? "Обычная" : "Standard"),
(lang == "ru" ? "Спутник" : "Satellite"),
(lang == "ru" ? "Гибрид" : "Hybrid")
]
mapTypeControl = UISegmentedControl(items: mapTypeItems)
mapTypeControl.selectedSegmentIndex = 0
mapTypeControl.addTarget(self, action: #selector(mapTypeChanged(_:)), for: .valueChanged)
mapTypeControl.translatesAutoresizingMaskIntoConstraints = false
saveButton = UIButton(type: .system)
let saveButtonTitle = (lang == "ru" ? "Сохранить" : "Save")
saveButton.setTitle(saveButtonTitle, for: .normal)
saveButton.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
saveButton.backgroundColor = presentationData.theme.list.itemAccentColor
saveButton.setTitleColor(.white, for: .normal)
saveButton.layer.cornerRadius = 12
saveButton.addTarget(self, action: #selector(savePressed), for: .touchUpInside)
saveButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(mapView)
view.addSubview(mapTypeControl)
view.addSubview(saveButton)
NSLayoutConstraint.activate([
mapView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
mapView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
saveButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
saveButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
saveButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16),
saveButton.heightAnchor.constraint(equalToConstant: 50),
mapTypeControl.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
mapTypeControl.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
mapTypeControl.bottomAnchor.constraint(equalTo: saveButton.topAnchor, constant: -12),
mapTypeControl.heightAnchor.constraint(equalToConstant: 32)
])
view.bringSubviewToFront(saveButton)
view.bringSubviewToFront(mapTypeControl)
loadSavedLocation()
}
@objc private func mapTapped(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: mapView)
let coordinate = mapView.convert(point, toCoordinateFrom: mapView)
addPinAt(coordinate: coordinate)
#if canImport(SGSimpleSettings)
SGSimpleSettings.shared.fakeLatitude = coordinate.latitude
SGSimpleSettings.shared.fakeLongitude = coordinate.longitude
SGSimpleSettings.shared.synchronizeShared()
#endif
}
@objc private func mapTypeChanged(_ sender: UISegmentedControl) {
switch sender.selectedSegmentIndex {
case 0:
mapView.mapType = .standard
case 1:
mapView.mapType = .satellite
case 2:
mapView.mapType = .hybrid
default:
break
}
}
@objc private func savePressed() {
#if canImport(SGSimpleSettings)
if let coordinate = currentPin?.coordinate {
SGSimpleSettings.shared.fakeLatitude = coordinate.latitude
SGSimpleSettings.shared.fakeLongitude = coordinate.longitude
SGSimpleSettings.shared.synchronizeShared()
onSave?()
}
#endif
navigateBack()
}
private func navigateBack() {
if let nav = self.navigationController, nav.viewControllers.count > 1 {
nav.popViewController(animated: true)
} else {
self.dismiss(animated: true)
}
}
private func loadSavedLocation() {
#if canImport(SGSimpleSettings)
let latitude = SGSimpleSettings.shared.fakeLatitude
let longitude = SGSimpleSettings.shared.fakeLongitude
if latitude != 0.0 && longitude != 0.0 {
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
addPinAt(coordinate: coordinate)
let region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 1000, longitudinalMeters: 1000)
mapView.setRegion(region, animated: false)
} else {
// Default to Moscow if no location is set
let defaultCoordinate = CLLocationCoordinate2D(latitude: 55.7558, longitude: 37.6176)
let region = MKCoordinateRegion(center: defaultCoordinate, latitudinalMeters: 10000, longitudinalMeters: 10000)
mapView.setRegion(region, animated: false)
}
#else
let defaultCoordinate = CLLocationCoordinate2D(latitude: 55.7558, longitude: 37.6176)
let region = MKCoordinateRegion(center: defaultCoordinate, latitudinalMeters: 10000, longitudinalMeters: 10000)
mapView.setRegion(region, animated: false)
#endif
}
private func addPinAt(coordinate: CLLocationCoordinate2D) {
if let existingPin = currentPin {
mapView.removeAnnotation(existingPin)
}
let annotation = MKPointAnnotation()
annotation.coordinate = coordinate
currentPin = annotation
mapView.addAnnotation(annotation)
}
}
extension FakeLocationPickerController: MKMapViewDelegate {
public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
if annotation is MKUserLocation {
return nil
}
let identifier = "FakeLocationPin"
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
if annotationView == nil {
annotationView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: identifier)
annotationView?.canShowCallout = true
} else {
annotationView?.annotation = annotation
}
return annotationView
}
}
+14
View File
@@ -0,0 +1,14 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGLocalPremium",
module_name = "SGLocalPremium",
srcs = glob(["Sources/**/*.swift"]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
],
visibility = ["//visibility:public"],
)
@@ -0,0 +1,81 @@
import Foundation
import SwiftSignalKit
// MARK: - GLEGram Local Premium
public class SGLocalPremium {
public static let shared = SGLocalPremium()
private var currentAccountId: String?
public var currentAccountPeerId: (id: Int64, namespace: Int32)?
private init() {}
// MARK: - Account Configuration
public func setAccountPeerId(_ peerId: Int64, namespace: Int32) {
self.currentAccountId = "\(namespace)_\(peerId)"
self.currentAccountPeerId = (id: peerId, namespace: namespace)
}
private func accountKey(_ key: String) -> String {
guard let accountId = currentAccountId else {
return key
}
return "\(key)_\(accountId)"
}
// MARK: - Main Setting (Per-Account)
public var emulatePremium: Bool {
get {
return UserDefaults.standard.bool(forKey: accountKey("localPremiumEmulate"))
}
set {
UserDefaults.standard.set(newValue, forKey: accountKey("localPremiumEmulate"))
UserDefaults.standard.synchronize()
}
}
// MARK: - Computed Properties
public var showPremiumBadge: Bool { return emulatePremium }
public var unlimitedPinnedChats: Bool { return emulatePremium }
public var unlimitedFolders: Bool { return emulatePremium }
public var unlimitedChatsPerFolder: Bool { return emulatePremium }
public var unlimitedSavedMessageTags: Bool { return emulatePremium }
public var allowFolderReordering: Bool { return emulatePremium }
public var shouldDisableServerSync: Bool { return emulatePremium }
// MARK: - Limit Overrides
public func getMaxPinnedChatCount(_ original: Int32) -> Int32 {
if unlimitedPinnedChats {
return Int32.max
}
return original
}
public func getMaxFoldersCount(_ original: Int32) -> Int32 {
if unlimitedFolders {
return Int32.max
}
return original
}
public func getMaxFolderChatsCount(_ original: Int32) -> Int32 {
if unlimitedChatsPerFolder {
return Int32.max
}
return original
}
// MARK: - Folder Reordering
public func canReorderAllChats(isPremium: Bool) -> Bool {
if isPremium {
return true
}
return allowFolderReordering
}
}
+22
View File
@@ -0,0 +1,22 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGSupporters",
module_name = "SGSupporters",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//Swiftgram/SGConfig:SGConfig",
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGRequests:SGRequests",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,67 @@
import Foundation
import UIKit
import SGConfig
import SGLogging
private let badgeImageCacheDir: String = {
let caches = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first ?? NSTemporaryDirectory()
let dir = caches + "/sg_badge_images"
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
return dir
}()
private var inMemoryCache: [String: UIImage] = [:]
private let cacheQueue = DispatchQueue(label: "sg.badge.image.cache", qos: .userInitiated)
/// Returns cached badge image synchronously (nil if not yet downloaded).
public func cachedBadgeImage(for url: String) -> UIImage? {
let key = cacheKey(url)
if let mem = inMemoryCache[key] { return mem }
let path = badgeImageCacheDir + "/" + key + ".png"
guard let data = FileManager.default.contents(atPath: path), let img = UIImage(data: data) else { return nil }
inMemoryCache[key] = img
return img
}
/// Downloads and caches badge images in the background. Call after check_user.
/// Only fetches URLs that pass isUrlSafeForBadgeImage (same-origin, no SSRF).
public func prefetchBadgeImages(urls: [String], allowedBaseURL: String? = nil) {
let base = allowedBaseURL ?? SG_CONFIG.supportersApiUrl
for urlString in urls {
guard isUrlSafeForBadgeImage(urlString, allowedBaseURL: base) else {
SGLogger.shared.log("SGSupporters", "BadgeImageCache: skipped unsafe URL")
continue
}
let key = cacheKey(urlString)
if inMemoryCache[key] != nil { continue }
let path = badgeImageCacheDir + "/" + key + ".png"
if FileManager.default.fileExists(atPath: path) {
if let data = FileManager.default.contents(atPath: path), let img = UIImage(data: data) {
inMemoryCache[key] = img
}
continue
}
guard let url = URL(string: urlString) else { continue }
cacheQueue.async {
guard let data = try? Data(contentsOf: url), let img = UIImage(data: data) else {
SGLogger.shared.log("SGSupporters", "BadgeImageCache: failed to download \(urlString)")
return
}
FileManager.default.createFile(atPath: path, contents: img.pngData())
DispatchQueue.main.async {
inMemoryCache[key] = img
NotificationCenter.default.post(name: Notification.Name("SGBadgeImageDidCache"), object: nil)
}
SGLogger.shared.log("SGSupporters", "BadgeImageCache: cached \(urlString)")
}
}
}
private func cacheKey(_ url: String) -> String {
let cleaned = url
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: "")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: ".", with: "_")
return String(cleaned.prefix(120))
}
@@ -0,0 +1,266 @@
import Foundation
// MARK: - GLEGram check_user response models
public struct GLEGramUserStatus: Equatable {
public let userId: String
public let badges: [GLEGramBadge]
public let subscription: GLEGramSubscription?
public let trial: GLEGramTrial?
public let donation: GLEGramDonation?
public let access: GLEGramAccess
public let glegramPromo: GLEGramPromo?
public let betaConfig: GLEGramBetaConfig?
public let hasActiveSubscription: Bool
public let hasActiveTrial: Bool
public let trialAvailable: Bool
public init(json: [String: Any]) {
self.userId = json["userId"] as? String ?? ""
self.badges = (json["badges"] as? [[String: Any]] ?? []).compactMap { GLEGramBadge(json: $0) }
self.subscription = (json["subscription"] as? [String: Any]).flatMap { GLEGramSubscription(json: $0) }
self.trial = (json["trial"] as? [String: Any]).flatMap { GLEGramTrial(json: $0) }
self.donation = (json["donation"] as? [String: Any]).flatMap { GLEGramDonation(json: $0) }
self.access = GLEGramAccess(json: json["access"] as? [String: Any] ?? [:])
self.glegramPromo = (json["glegramPromo"] as? [String: Any]).flatMap { GLEGramPromo(json: $0) }
self.betaConfig = (json["betaConfig"] as? [String: Any]).flatMap { GLEGramBetaConfig(json: $0) }
self.hasActiveSubscription = json["hasActiveSubscription"] as? Bool ?? false
self.hasActiveTrial = json["hasActiveTrial"] as? Bool ?? false
self.trialAvailable = json["trialAvailable"] as? Bool ?? false
}
}
public struct GLEGramBadge: Equatable {
public let id: String
public let name: String
public let color: String
public let displayMode: String // "text" or "image"
public let image: String? // relative URL for image badges
public let uiEnabled: Bool
public let uiConfig: GLEGramBadgeUIConfig?
public init?(json: [String: Any]) {
guard let id = json["id"] as? String else { return nil }
self.id = id
self.name = json["name"] as? String ?? id
self.color = json["color"] as? String ?? "#34C759"
self.displayMode = json["displayMode"] as? String ?? "text"
self.image = json["image"] as? String
self.uiEnabled = json["uiEnabled"] as? Bool ?? false
self.uiConfig = (json["uiConfig"] as? [String: Any]).flatMap { GLEGramBadgeUIConfig(json: $0) }
}
}
public struct GLEGramBadgeUIConfig: Equatable {
public let title: String
public let description: String
public let buttons: [GLEGramBadgeButton]
public init?(json: [String: Any]) {
self.title = json["title"] as? String ?? ""
self.description = json["description"] as? String ?? ""
self.buttons = (json["buttons"] as? [[String: Any]] ?? []).compactMap { GLEGramBadgeButton(json: $0) }
}
}
public struct GLEGramBadgeButton: Equatable {
public let label: String
public let url: String
public init?(json: [String: Any]) {
guard let label = json["label"] as? String, let url = json["url"] as? String else { return nil }
self.label = label
self.url = url
}
}
public struct GLEGramSubscription: Equatable {
public let planId: String
public let startedAt: String
public let expiresAt: String
public let active: Bool
public init?(json: [String: Any]) {
self.planId = json["planId"] as? String ?? ""
self.startedAt = json["startedAt"] as? String ?? ""
self.expiresAt = json["expiresAt"] as? String ?? ""
self.active = json["active"] as? Bool ?? false
}
}
public struct GLEGramTrial: Equatable {
public let startedAt: String
public let expiresAt: String
public let active: Bool
public let alreadyUsed: Bool
public init?(json: [String: Any]) {
self.startedAt = json["startedAt"] as? String ?? ""
self.expiresAt = json["expiresAt"] as? String ?? ""
self.active = json["active"] as? Bool ?? false
self.alreadyUsed = json["alreadyUsed"] as? Bool ?? false
}
}
public struct GLEGramDonation: Equatable {
public let amount: Int
public let lastDonatedAt: String
public let betaAccess: Bool
public init?(json: [String: Any]) {
self.amount = json["amount"] as? Int ?? 0
self.lastDonatedAt = json["lastDonatedAt"] as? String ?? ""
self.betaAccess = json["betaAccess"] as? Bool ?? false
}
}
public struct GLEGramAccess: Equatable {
// Obfuscated storage: actual bits XOR'd with per-instance random salt.
// Prevents trivial memory scanning for plain true/false values.
private let _enc: UInt32
private let _salt: UInt32
/// HMAC access token (base64). Used by integrity layer to verify flags haven't been tampered.
public let accessToken: String?
public var glegramTab: Bool {
return (_enc ^ _salt) & 0x1 != 0
}
public var betaBuilds: Bool {
return (_enc ^ _salt) & 0x2 != 0
}
public init(json: [String: Any]) {
let tab = json["glegramTab"] as? Bool ?? false
let beta = json["betaBuilds"] as? Bool ?? false
let bits: UInt32 = (tab ? 1 : 0) | (beta ? 2 : 0)
let salt = UInt32.random(in: 1...UInt32.max)
self._enc = bits ^ salt
self._salt = salt
self.accessToken = json["_accessToken"] as? String
}
public static func == (lhs: GLEGramAccess, rhs: GLEGramAccess) -> Bool {
return lhs.glegramTab == rhs.glegramTab && lhs.betaBuilds == rhs.betaBuilds
}
}
public struct GLEGramPromo: Equatable {
public let title: String
public let subtitle: String
public let features: [String]
public let trialButtonText: String
public let subscribeButtonText: String
public let miniAppUrl: String?
public init?(json: [String: Any]) {
self.title = json["title"] as? String ?? ""
self.subtitle = json["subtitle"] as? String ?? ""
self.features = json["features"] as? [String] ?? []
self.trialButtonText = json["trialButtonText"] as? String ?? ""
self.subscribeButtonText = json["subscribeButtonText"] as? String ?? ""
self.miniAppUrl = json["miniAppUrl"] as? String
}
}
public struct GLEGramBetaConfig: Equatable {
public let channelId: String?
public let channelUrl: String?
public let buildUrl: String?
public init?(json: [String: Any]) {
self.channelId = json["channelId"] as? String
self.channelUrl = json["channelUrl"] as? String
self.buildUrl = json["buildUrl"] as? String
}
}
// MARK: - JSON serialization for cache
extension GLEGramUserStatus {
public func toJSON() -> [String: Any] {
var dict: [String: Any] = [
"userId": userId,
"badges": badges.map { $0.toJSON() },
"hasActiveSubscription": hasActiveSubscription,
"hasActiveTrial": hasActiveTrial,
"trialAvailable": trialAvailable,
"access": access.toJSON()
]
if let s = subscription { dict["subscription"] = s.toJSON() }
if let t = trial { dict["trial"] = t.toJSON() }
if let d = donation { dict["donation"] = d.toJSON() }
if let p = glegramPromo { dict["glegramPromo"] = p.toJSON() }
if let b = betaConfig { dict["betaConfig"] = b.toJSON() }
return dict
}
}
extension GLEGramBadge {
func toJSON() -> [String: Any] {
var dict: [String: Any] = [
"id": id, "name": name, "color": color,
"displayMode": displayMode, "uiEnabled": uiEnabled
]
if let img = image { dict["image"] = img }
if let ui = uiConfig { dict["uiConfig"] = ui.toJSON() }
return dict
}
}
extension GLEGramBadgeUIConfig {
func toJSON() -> [String: Any] {
return [
"title": title,
"description": description,
"buttons": buttons.map { ["label": $0.label, "url": $0.url] }
]
}
}
extension GLEGramSubscription {
func toJSON() -> [String: Any] {
return ["planId": planId, "startedAt": startedAt, "expiresAt": expiresAt, "active": active]
}
}
extension GLEGramTrial {
func toJSON() -> [String: Any] {
return ["startedAt": startedAt, "expiresAt": expiresAt, "active": active, "alreadyUsed": alreadyUsed]
}
}
extension GLEGramDonation {
func toJSON() -> [String: Any] {
return ["amount": amount, "lastDonatedAt": lastDonatedAt, "betaAccess": betaAccess]
}
}
extension GLEGramAccess {
func toJSON() -> [String: Any] {
var d: [String: Any] = ["glegramTab": glegramTab, "betaBuilds": betaBuilds]
if let t = accessToken { d["_accessToken"] = t }
return d
}
}
extension GLEGramPromo {
func toJSON() -> [String: Any] {
var dict: [String: Any] = [
"title": title, "subtitle": subtitle, "features": features,
"trialButtonText": trialButtonText, "subscribeButtonText": subscribeButtonText
]
if let url = miniAppUrl { dict["miniAppUrl"] = url }
return dict
}
}
extension GLEGramBetaConfig {
func toJSON() -> [String: Any] {
var dict: [String: Any] = [:]
if let v = channelId { dict["channelId"] = v }
if let v = channelUrl { dict["channelUrl"] = v }
if let v = buildUrl { dict["buildUrl"] = v }
return dict
}
}
+933
View File
@@ -0,0 +1,933 @@
import Foundation
import UIKit
import SwiftSignalKit
import SGConfig
import SGLogging
import SGRequests
import SGSimpleSettings
private func supportersRequest(
_ request: URLRequest,
baseURL: String
) -> Signal<(Data, URLResponse?), Error?> {
let pins = SG_CONFIG.supportersPinnedCertHashes
guard !pins.isEmpty, let host = URL(string: baseURL)?.host else {
return requestsCustom(request: request)
}
return requestsCustomWithPinning(request: request, host: host, pinnedHashes: pins)
}
public enum SupportersAPIError: Error {
case notConfigured
case network
case invalidResponse
/// HTTP 429 rate limit. Response body is plain JSON, not encrypted.
case tooManyRequests
}
/// Single badge from server: id, display name, hex color, display mode, image URL.
public struct SupportersBadge: Equatable {
public let id: String
public let name: String
public let colorHex: String
public let displayMode: String
public let imageURL: String?
public init(id: String, name: String, colorHex: String, displayMode: String = "text", imageURL: String? = nil) {
self.id = id
self.name = name
self.colorHex = colorHex
self.displayMode = displayMode
self.imageURL = imageURL
}
}
/// Checks whether the given user ID is in the supporters list (encrypted request/response).
public func checkIsSupporter(
userId: Int64,
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<Bool, SupportersAPIError> {
return Signal { subscriber in
let urlString = baseURL.hasSuffix("/") ? "\(baseURL)api/encrypted" : "\(baseURL)/api/encrypted"
guard let url = URL(string: urlString) else {
subscriber.putError(.notConfigured)
return EmptyDisposable
}
let payload: [String: Any] = [
"action": "check",
"payload": ["userId": String(userId)]
]
let body: String
do {
body = try SupportersCrypto.encrypt(payload, key: aesKey, hmacKey: hmacKey)
} catch {
subscriber.putError(.invalidResponse)
return EmptyDisposable
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(body.utf8)
let completed = Atomic<Bool>(value: false)
let disposable = supportersRequest(request, baseURL: baseURL).start(
next: { data, response in
guard completed.swap(true) == false else { return }
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
if code == 429 {
subscriber.putError(.tooManyRequests)
return
}
guard let text = String(data: data, encoding: .utf8) else {
subscriber.putError(.invalidResponse)
return
}
do {
let decrypted = try SupportersCrypto.decrypt(text, key: aesKey, hmacKey: hmacKey)
let supported = (decrypted["supported"] as? Bool) ?? false
subscriber.putNext(supported)
subscriber.putCompletion()
} catch {
subscriber.putError(.invalidResponse)
}
},
error: { _ in
guard completed.swap(true) == false else { return }
subscriber.putError(.network)
}
)
return ActionDisposable {
if !completed.with({ $0 }) {
disposable.dispose()
}
}
}
}
/// Convenience: check using SG_CONFIG supporters URL and key if configured.
public func checkIsSupporterIfConfigured(userId: Int64) -> Signal<Bool, SupportersAPIError>? {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
return nil
}
return checkIsSupporter(userId: userId, baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey)
}
// MARK: - Badges (list from server, cache in Keychain encrypted, not in files)
private let kSupportersCacheAccount = "sg_supporters_cache"
private func loadCacheFile() -> [String: Any] {
supportersSecureLoadJSON(account: kSupportersCacheAccount) ?? [:]
}
private func saveCacheFile(_ dict: [String: Any]) {
_ = supportersSecureSaveJSON(dict, account: kSupportersCacheAccount)
}
/// Fetches badges and assignments (encrypted). Used to populate cache.
public func fetchBadges(baseURL: String, aesKey: String, hmacKey: String? = nil) -> Signal<(badges: [SupportersBadge], assignments: [String: [String]]), SupportersAPIError> {
return Signal { subscriber in
let urlString = baseURL.hasSuffix("/") ? "\(baseURL)api/encrypted" : "\(baseURL)/api/encrypted"
guard let url = URL(string: urlString) else {
subscriber.putError(.notConfigured)
return EmptyDisposable
}
let payload: [String: Any] = ["action": "list_badges", "payload": [:] as [String: Any]]
let body: String
do {
body = try SupportersCrypto.encrypt(payload, key: aesKey, hmacKey: hmacKey)
} catch {
subscriber.putError(.invalidResponse)
return EmptyDisposable
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(body.utf8)
SGLogger.shared.log("SGSupporters", "fetchBadges: POST \(urlString)")
let completed = Atomic<Bool>(value: false)
let disposable = supportersRequest(request, baseURL: baseURL).start(
next: { data, response in
guard completed.swap(true) == false else { return }
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
SGLogger.shared.log("SGSupporters", "fetchBadges: response status=\(code), bodyLen=\(data.count)")
if code == 429 {
subscriber.putError(.tooManyRequests)
return
}
guard let text = String(data: data, encoding: .utf8) else {
SGLogger.shared.log("SGSupporters", "fetchBadges: body not UTF-8")
subscriber.putError(.invalidResponse)
return
}
do {
let decrypted = try SupportersCrypto.decrypt(text, key: aesKey, hmacKey: hmacKey)
let badgesRaw = decrypted["badges"] as? [[String: Any]] ?? []
let assignmentsRaw = decrypted["assignments"] as? [String: [String]] ?? [:]
let badges: [SupportersBadge] = badgesRaw.compactMap { b in
guard let id = b["id"] as? String else { return nil }
let name = b["name"] as? String ?? id
let colorHex = b["color"] as? String ?? "#34C759"
let displayMode = b["displayMode"] as? String ?? "text"
let imageURL = b["image"] as? String
return SupportersBadge(id: id, name: name, colorHex: colorHex, displayMode: displayMode, imageURL: imageURL)
}
SGLogger.shared.log("SGSupporters", "fetchBadges: decrypted ok — badges=\(badges.count)")
subscriber.putNext((badges: badges, assignments: assignmentsRaw))
subscriber.putCompletion()
} catch {
SGLogger.shared.log("SGSupporters", "fetchBadges: decrypt failed — \(String(describing: error))")
subscriber.putError(.invalidResponse)
}
},
error: { err in
guard completed.swap(true) == false else { return }
SGLogger.shared.log("SGSupporters", "fetchBadges: network error — \(String(describing: err))")
subscriber.putError(.network)
}
)
return ActionDisposable {
if !completed.with({ $0 }) {
disposable.dispose()
}
}
}
}
func setCachedBadges(_ badges: [SupportersBadge]) {
var cache = loadCacheFile()
cache["badges"] = badges.map { b -> [String: Any] in
var d: [String: Any] = ["id": b.id, "name": b.name, "color": b.colorHex, "displayMode": b.displayMode]
if let img = b.imageURL { d["image"] = img }
return d
}
saveCacheFile(cache)
}
func setCachedAssignments(_ assignments: [String: [String]]) {
var cache = loadCacheFile()
cache["assignments"] = assignments
saveCacheFile(cache)
}
private func loadCachedBadges() -> [SupportersBadge] {
let cache = loadCacheFile()
guard let raw = cache["badges"] as? [[String: Any]] else { return [] }
return raw.compactMap { b in
guard let id = b["id"] as? String else { return nil }
let name = b["name"] as? String ?? id
let colorHex = b["color"] as? String ?? "#34C759"
let displayMode = b["displayMode"] as? String ?? "text"
let imageURL = b["image"] as? String
return SupportersBadge(id: id, name: name, colorHex: colorHex, displayMode: displayMode, imageURL: imageURL)
}
}
private func loadCachedAssignments() -> [String: [String]] {
let cache = loadCacheFile()
guard let raw = cache["assignments"] as? [String: [String]] else { return [:] }
return raw
}
private let refreshStarted = Atomic<Bool>(value: false)
/// Call when app becomes active or at launch to refresh badges cache. Uses SG_CONFIG if set.
public func refreshSupportersCacheIfConfigured() {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
SGLogger.shared.log("SGSupporters", "refreshSupportersCacheIfConfigured: skip — URL or key not set (url=\(SG_CONFIG.supportersApiUrl ?? "nil"), key=\(SG_CONFIG.supportersAesKey != nil ? "***" : "nil"))")
return
}
if refreshStarted.swap(true) {
SGLogger.shared.log("SGSupporters", "refreshSupportersCacheIfConfigured: refresh already in progress, skip")
return
}
SGLogger.shared.log("SGSupporters", "refreshSupportersCacheIfConfigured: start fetch \(baseURL)")
_ = fetchBadges(baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey).start(next: { data in
setCachedBadges(data.badges)
setCachedAssignments(data.assignments)
let uniqueUsers = Set(data.assignments.values.flatMap { $0 })
SGLogger.shared.log("SGSupporters", "refreshSupportersCacheIfConfigured: ok — badges=\(data.badges.count), unique users=\(uniqueUsers.count)")
// Prefetch image badges from list_badges result
let imageURLs = data.badges.compactMap { badge -> String? in
guard badge.displayMode == "image", let img = badge.imageURL else { return nil }
if img.hasPrefix("http") {
return isUrlSafeForBadgeImage(img, allowedBaseURL: baseURL) ? img : nil
}
guard img.hasPrefix("/") else { return nil }
let baseNorm = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
let full = baseNorm + img
return isUrlSafeForBadgeImage(full, allowedBaseURL: baseURL) ? full : nil
}
if !imageURLs.isEmpty {
prefetchBadgeImages(urls: imageURLs, allowedBaseURL: baseURL)
}
}, error: { err in
_ = refreshStarted.swap(false)
SGLogger.shared.log("SGSupporters", "refreshSupportersCacheIfConfigured: error — \(String(describing: err))")
}, completed: {
_ = refreshStarted.swap(false)
SGLogger.shared.log("SGSupporters", "refreshSupportersCacheIfConfigured: completed")
})
}
/// Full badge info for a user: name, color, display mode, image URL. Merges list_badges cache with check_user cache.
public func badges(forUserId userId: Int64) -> [(name: String, color: UIColor, displayMode: String, imageURL: String?)] {
let userIdStr = String(userId)
let baseURL = SG_CONFIG.supportersApiUrl
if let status = loadCachedUserStatus(userId: userIdStr) {
return status.badges.map { badge in
let color = UIColor(hex: badge.color) ?? UIColor(red: 52/255, green: 199/255, blue: 89/255, alpha: 1)
var fullImageURL: String? = nil
if let img = badge.image {
if img.hasPrefix("http") {
fullImageURL = isUrlSafeForBadgeImage(img, allowedBaseURL: baseURL) ? img : nil
} else if let base = baseURL, img.hasPrefix("/") {
let baseNorm = base.hasSuffix("/") ? String(base.dropLast()) : base
let full = baseNorm + img
fullImageURL = isUrlSafeForBadgeImage(full, allowedBaseURL: base) ? full : nil
}
}
return (name: badge.name, color: color, displayMode: badge.displayMode, imageURL: fullImageURL)
}
}
let badgesList = loadCachedBadges()
let assignments = loadCachedAssignments()
var result: [(name: String, color: UIColor, displayMode: String, imageURL: String?)] = []
for badge in badgesList {
let userIds = assignments[badge.id] ?? []
if userIds.contains(userIdStr) {
let color = UIColor(hex: badge.colorHex) ?? UIColor(red: 52/255, green: 199/255, blue: 89/255, alpha: 1)
var fullImageURL: String? = nil
if let img = badge.imageURL {
if img.hasPrefix("http") {
fullImageURL = isUrlSafeForBadgeImage(img, allowedBaseURL: baseURL) ? img : nil
} else if let base = baseURL, img.hasPrefix("/") {
let baseNorm = base.hasSuffix("/") ? String(base.dropLast()) : base
let full = baseNorm + img
fullImageURL = isUrlSafeForBadgeImage(full, allowedBaseURL: base) ? full : nil
}
}
result.append((name: badge.name, color: color, displayMode: badge.displayMode, imageURL: fullImageURL))
}
}
if !result.isEmpty {
SGLogger.shared.log("SGSupporters", "badges(userId=\(userIdStr)): \(result.map { $0.name }.joined(separator: ", "))")
}
return result
}
/// Legacy: true if user has at least one badge.
/// Integrity-gated: also verifies text segment checksum.
public func isSupporter(userId: Int64) -> Bool {
guard SupportersIntegrity.textOK() else { return false }
return !badges(forUserId: userId).isEmpty
}
// MARK: - GLEGram check_user (full user status, Keychain encrypted, not in files)
private let kUserStatusCacheAccount = "sg_glegram_user_status"
private let userStatusCacheLock = NSLock()
private let kVerifiedUserIds = "_verified"
private func loadAllCachedUserStatuses() -> [String: [String: Any]] {
guard let j = supportersSecureLoadJSON(account: kUserStatusCacheAccount) else {
return [:]
}
var result: [String: [String: Any]] = [:]
for (key, value) in j {
if key == "_multi" || key == kVerifiedUserIds { continue }
if let statusDict = value as? [String: Any] {
result[key] = statusDict
}
}
return result
}
private func loadVerifiedUserIds() -> Set<String> {
guard let j = supportersSecureLoadJSON(account: kUserStatusCacheAccount) else {
return []
}
if let arr = j[kVerifiedUserIds] as? [String], !arr.isEmpty {
return Set(arr)
}
// Backward compat: no _verified yet treat existing entries as verified
var keys: Set<String> = []
for (key, value) in j {
if key != "_multi" && key != kVerifiedUserIds, value is [String: Any] {
keys.insert(key)
}
}
return keys
}
private func loadCachedUserStatus(userId: String? = nil) -> GLEGramUserStatus? {
let all = loadAllCachedUserStatuses()
if let userId = userId {
guard let json = all[userId] else { return nil }
return GLEGramUserStatus(json: json)
}
// Return first available status (backward compat)
guard let first = all.values.first else { return nil }
return GLEGramUserStatus(json: first)
}
private func saveCachedUserStatus(_ status: GLEGramUserStatus) {
userStatusCacheLock.lock()
defer { userStatusCacheLock.unlock() }
var all: [String: [String: Any]] = [:]
var verified: Set<String> = []
if let j = supportersSecureLoadJSON(account: kUserStatusCacheAccount) {
verified = Set((j[kVerifiedUserIds] as? [String]) ?? [])
for (key, value) in j {
if key == "_multi" || key == kVerifiedUserIds { continue }
if let statusDict = value as? [String: Any] {
all[key] = statusDict
}
}
}
all[status.userId] = status.toJSON()
verified.insert(status.userId)
var dict: [String: Any] = ["_multi": true, kVerifiedUserIds: Array(verified)]
for (key, value) in all {
dict[key] = value
}
_ = supportersSecureSaveJSON(dict, account: kUserStatusCacheAccount)
}
/// Cached GLEGram user status for a specific user ID (or first available).
public func cachedGLEGramUserStatus(userId: String? = nil) -> GLEGramUserStatus? {
return loadCachedUserStatus(userId: userId)
}
/// Aggregate access across cached accounts. If validUserIds is set, only those accounts are considered.
/// Only entries from our check_user API (_verified) are trusted prevents injection of fake IDs.
/// Access is verified through multiple independent integrity layers (token + text checksum + accumulator).
public func cachedAggregateAccess(validUserIds: Set<String>? = nil) -> GLEGramAccess {
var all = loadAllCachedUserStatuses()
let verified = loadVerifiedUserIds()
all = all.filter { verified.contains($0.key) }
if let ids = validUserIds {
all = all.filter { ids.contains($0.key) }
}
var glegramTab = false
var betaBuilds = false
var tokenVerified = false
for (userId, json) in all {
let status = GLEGramUserStatus(json: json)
if status.access.glegramTab { glegramTab = true }
if status.access.betaBuilds { betaBuilds = true }
// Integrity layer: verify per-user access token
if let tokenB64 = status.access.accessToken,
let tokenData = Data(base64Encoded: tokenB64),
let hmacB64 = SG_CONFIG.supportersHmacKey ?? SG_CONFIG.supportersAesKey {
let keyData = SupportersCrypto.normalizeKeyData(hmacB64)
if SupportersIntegrity.verifyAccessToken(
tokenData,
userId: userId,
glegramTab: status.access.glegramTab,
betaBuilds: status.access.betaBuilds,
hmacKeyData: keyData
) {
tokenVerified = true
} else {
// Token mismatch flags were tampered in cache
SGLogger.shared.log("SGIntegrity", "access token mismatch for userId=\(userId)")
glegramTab = false
betaBuilds = false
}
}
}
// Integrity layer: text segment checksum
if !SupportersIntegrity.textOK() {
SGLogger.shared.log("SGIntegrity", "text segment modified — revoking access")
glegramTab = false
betaBuilds = false
}
// Re-validate accumulator from cached state
if tokenVerified {
SupportersIntegrity.validate(
cryptoSucceeded: tokenVerified,
cacheDecrypted: !all.isEmpty,
glegramTab: glegramTab,
betaBuilds: betaBuilds
)
}
return GLEGramAccess(json: ["glegramTab": glegramTab, "betaBuilds": betaBuilds])
}
/// Returns true if ANY cached account has an active subscription.
/// Integrity-gated: also verifies text segment checksum.
public func hasAnyCachedSubscription() -> Bool {
guard SupportersIntegrity.textOK() else { return false }
let all = loadAllCachedUserStatuses()
return all.values.contains { json in
GLEGramUserStatus(json: json).hasActiveSubscription
}
}
/// Returns true if ANY cached account has an active trial.
/// Integrity-gated: also verifies text segment checksum.
public func hasAnyCachedTrial() -> Bool {
guard SupportersIntegrity.textOK() else { return false }
let all = loadAllCachedUserStatuses()
return all.values.contains { json in
GLEGramUserStatus(json: json).hasActiveTrial
}
}
/// Returns the first betaConfig found across cached accounts that has betaBuilds access.
/// Integrity-gated: returns nil if text segment has been modified.
public func cachedAggregateBetaConfig(validUserIds: Set<String>? = nil) -> GLEGramBetaConfig? {
guard SupportersIntegrity.textOK() else { return nil }
var all = loadAllCachedUserStatuses()
let verified = loadVerifiedUserIds()
all = all.filter { verified.contains($0.key) }
if let ids = validUserIds {
all = all.filter { ids.contains($0.key) }
}
for (_, json) in all {
let status = GLEGramUserStatus(json: json)
if status.access.betaBuilds, let config = status.betaConfig {
return config
}
}
return nil
}
/// Returns the first promo found across cached accounts (for paywall display).
public func cachedAggregatePromo() -> (promo: GLEGramPromo, trialAvailable: Bool)? {
let all = loadAllCachedUserStatuses()
for (_, json) in all {
let status = GLEGramUserStatus(json: json)
if let promo = status.glegramPromo {
return (promo: promo, trialAvailable: status.trialAvailable)
}
}
return nil
}
/// Full user status: badges, subscription, trial, access, promo, beta config.
public func checkUser(
userId: Int64,
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<GLEGramUserStatus, SupportersAPIError> {
return encryptedAPICall(
action: "check_user",
payload: ["userId": String(userId)],
baseURL: baseURL,
aesKey: aesKey,
hmacKey: hmacKey
) |> map { json in
// Generate integrity access token and inject into JSON before parsing
var enrichedJSON = json
let userId = json["userId"] as? String ?? String(userId)
let accessJSON = json["access"] as? [String: Any] ?? [:]
let tab = accessJSON["glegramTab"] as? Bool ?? false
let beta = accessJSON["betaBuilds"] as? Bool ?? false
if let hmacB64 = SG_CONFIG.supportersHmacKey ?? SG_CONFIG.supportersAesKey {
let keyData = SupportersCrypto.normalizeKeyData(hmacB64)
let token = SupportersIntegrity.computeAccessToken(
userId: userId, glegramTab: tab, betaBuilds: beta, hmacKeyData: keyData
)
var accessWithToken = accessJSON
accessWithToken["_accessToken"] = token.base64EncodedString()
enrichedJSON["access"] = accessWithToken
}
let status = GLEGramUserStatus(json: enrichedJSON)
saveCachedUserStatus(status)
// Integrity: validate all layers after successful check_user
SupportersIntegrity.validate(
cryptoSucceeded: true,
cacheDecrypted: true,
glegramTab: tab,
betaBuilds: beta
)
// Parse gated features from check_user response (if server embeds them).
// Write to SGSimpleSettings on main queue to avoid threading issues.
if let gatedArray = json["gatedFeatures"] as? [[String: Any]] {
let features = gatedArray.compactMap { f -> (key: String, deeplinkPath: String)? in
guard let key = f["key"] as? String,
let path = f["deeplinkPath"] as? String else { return nil }
return (key: key, deeplinkPath: path)
}
DispatchQueue.main.async {
SGSimpleSettings.shared.updateGatedFeatures(features)
}
SGLogger.shared.log("SGSupporters", "check_user: parsed \(features.count) gatedFeatures")
}
if let unlockedArray = json["unlockedFeatures"] as? [String] {
DispatchQueue.main.async {
var current = SGSimpleSettings.shared.unlockedFeatureKeys
for k in unlockedArray {
if !current.contains(k) { current.append(k) }
}
SGSimpleSettings.shared.unlockedFeatureKeys = current
}
SGLogger.shared.log("SGSupporters", "check_user: parsed \(unlockedArray.count) unlockedFeatures")
}
return status
}
}
/// Convenience: check_user using SG_CONFIG.
public func checkUserIfConfigured(userId: Int64) -> Signal<GLEGramUserStatus, SupportersAPIError>? {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
return nil
}
return checkUser(userId: userId, baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey)
}
/// Start 7-day trial (one-time per Telegram ID).
public func startTrial(
userId: Int64,
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<GLEGramTrial?, SupportersAPIError> {
return encryptedAPICall(
action: "start_trial",
payload: ["userId": String(userId)],
baseURL: baseURL,
aesKey: aesKey,
hmacKey: hmacKey
) |> map { json in
(json["trial"] as? [String: Any]).flatMap { GLEGramTrial(json: $0) }
}
}
/// Convenience: start_trial using SG_CONFIG.
public func startTrialIfConfigured(userId: Int64) -> Signal<GLEGramTrial?, SupportersAPIError>? {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
return nil
}
return startTrial(userId: userId, baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey)
}
/// On app launch: call check_user and refresh badges cache.
public func refreshGLEGramStatusIfConfigured(userId: Int64) {
guard let signal = checkUserIfConfigured(userId: userId) else { return }
SGLogger.shared.log("SGSupporters", "refreshGLEGramStatus: starting check_user for \(userId)")
_ = signal.start(next: { status in
SGLogger.shared.log("SGSupporters", "refreshGLEGramStatus: ok — access.glegramTab=\(status.access.glegramTab), badges=\(status.badges.count), sub=\(status.hasActiveSubscription), trial=\(status.hasActiveTrial)")
let baseURL = SG_CONFIG.supportersApiUrl
let imageURLs = status.badges.compactMap { badge -> String? in
guard badge.displayMode == "image", let img = badge.image else { return nil }
if img.hasPrefix("http") {
return isUrlSafeForBadgeImage(img, allowedBaseURL: baseURL) ? img : nil
}
guard let base = baseURL, img.hasPrefix("/") else { return nil }
let baseNorm = base.hasSuffix("/") ? String(base.dropLast()) : base
let full = baseNorm + img
return isUrlSafeForBadgeImage(full, allowedBaseURL: base) ? full : nil
}
if !imageURLs.isEmpty {
prefetchBadgeImages(urls: imageURLs, allowedBaseURL: baseURL)
}
}, error: { err in
let msg: String
if case .tooManyRequests = err { msg = "429 Too Many Requests" }
else { msg = String(describing: err) }
SGLogger.shared.log("SGSupporters", "refreshGLEGramStatus: error — \(msg)")
})
}
/// Prune cache to only keep statuses for accounts that exist in the app.
public func pruneCachedUserStatuses(keepingUserIds: Set<String>) {
userStatusCacheLock.lock()
defer { userStatusCacheLock.unlock() }
guard let j = supportersSecureLoadJSON(account: kUserStatusCacheAccount) else { return }
var all: [String: [String: Any]] = [:]
var verified = Set<String>((j[kVerifiedUserIds] as? [String]) ?? [])
for (key, value) in j {
if key == "_multi" || key == kVerifiedUserIds { continue }
if let statusDict = value as? [String: Any] {
all[key] = statusDict
}
}
let before = all.count
all = all.filter { keepingUserIds.contains($0.key) }
verified = verified.intersection(keepingUserIds)
if all.count != before {
var dict: [String: Any] = ["_multi": true, kVerifiedUserIds: Array(verified)]
for (key, value) in all {
dict[key] = value
}
_ = supportersSecureSaveJSON(dict, account: kUserStatusCacheAccount)
SGLogger.shared.log("SGSupporters", "pruneCachedUserStatuses: removed \(before - all.count) stale entries")
}
}
/// Fetches check_user for all userIds in parallel. Completes when all finish (success or error).
/// Use before checking cachedAggregateAccess when current account has no beta.
public func fetchAllUserStatusesIfConfigured(userIds: [Int64]) -> Signal<Never, NoError> {
return Signal { subscriber in
let total = userIds.count
if total == 0 {
subscriber.putCompletion()
return EmptyDisposable
}
let completed = Atomic<Int>(value: 0)
let disposable = DisposableSet()
for userId in userIds {
guard let s = checkUserIfConfigured(userId: userId) else {
if completed.modify({ $0 + 1 }) == total { subscriber.putCompletion() }
continue
}
let d = s.start(error: { _ in
if completed.modify({ $0 + 1 }) == total { subscriber.putCompletion() }
}, completed: {
if completed.modify({ $0 + 1 }) == total { subscriber.putCompletion() }
})
disposable.add(d)
}
return disposable
}
}
/// Check all accounts at once (multi-account access support). Prunes cache to match app accounts.
public func refreshGLEGramStatusForAllAccounts(userIds: [Int64]) {
SGLogger.shared.log("SGSupporters", "refreshGLEGramStatusForAllAccounts: \(userIds.count) accounts")
let validUserIds = Set(userIds.map { String($0) })
pruneCachedUserStatuses(keepingUserIds: validUserIds)
for userId in userIds {
refreshGLEGramStatusIfConfigured(userId: userId)
}
}
// MARK: - Generic encrypted API call helper
private func encryptedAPICall(
action: String,
payload: [String: Any],
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<[String: Any], SupportersAPIError> {
return Signal { subscriber in
let urlString = baseURL.hasSuffix("/") ? "\(baseURL)api/encrypted" : "\(baseURL)/api/encrypted"
guard let url = URL(string: urlString) else {
subscriber.putError(.notConfigured)
return EmptyDisposable
}
let requestPayload: [String: Any] = ["action": action, "payload": payload]
let body: String
do {
body = try SupportersCrypto.encrypt(requestPayload, key: aesKey, hmacKey: hmacKey)
} catch {
subscriber.putError(.invalidResponse)
return EmptyDisposable
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(body.utf8)
SGLogger.shared.log("SGSupporters", "\(action): POST \(urlString)")
let completed = Atomic<Bool>(value: false)
let disposable = supportersRequest(request, baseURL: baseURL).start(
next: { data, response in
guard completed.swap(true) == false else { return }
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
SGLogger.shared.log("SGSupporters", "\(action): status=\(code)")
if code == 429 {
subscriber.putError(.tooManyRequests)
return
}
guard let text = String(data: data, encoding: .utf8) else {
subscriber.putError(.invalidResponse)
return
}
do {
let decrypted = try SupportersCrypto.decrypt(text, key: aesKey, hmacKey: hmacKey)
if let ok = decrypted["ok"] as? Bool, !ok {
let errMsg = decrypted["error"] as? String ?? "Unknown error"
SGLogger.shared.log("SGSupporters", "\(action): server error — \(errMsg)")
subscriber.putError(.invalidResponse)
return
}
// Integrity: seal text checksum on first successful crypto verification
SupportersIntegrity.seal()
subscriber.putNext(decrypted)
subscriber.putCompletion()
} catch {
SGLogger.shared.log("SGSupporters", "\(action): decrypt failed — \(String(describing: error))")
subscriber.putError(.invalidResponse)
}
},
error: { err in
guard completed.swap(true) == false else { return }
SGLogger.shared.log("SGSupporters", "\(action): network error — \(String(describing: err))")
subscriber.putError(.network)
}
)
return ActionDisposable {
if !completed.with({ $0 }) {
disposable.dispose()
}
}
}
}
// MARK: - Gated Features (encrypted, same as Supporters DRM)
/// Fetch list of gated features from server (encrypted).
public func fetchGatedFeatures(
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<[(key: String, deeplinkPath: String)], SupportersAPIError> {
return encryptedAPICall(
action: "gated_features",
payload: [:],
baseURL: baseURL,
aesKey: aesKey,
hmacKey: hmacKey
) |> map { json in
guard let features = json["gatedFeatures"] as? [[String: Any]] else { return [] }
return features.compactMap { f in
guard let key = f["key"] as? String,
let path = f["deeplinkPath"] as? String else { return nil }
return (key: key, deeplinkPath: path)
}
}
}
/// Fetch unlocked features for a specific user (encrypted).
public func fetchUnlockedFeatures(
userId: Int64,
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<[String], SupportersAPIError> {
return encryptedAPICall(
action: "unlocked_features",
payload: ["userId": String(userId)],
baseURL: baseURL,
aesKey: aesKey,
hmacKey: hmacKey
) |> map { json in
return json["unlockedKeys"] as? [String] ?? []
}
}
/// Unlock a feature via deeplink path (encrypted).
/// Returns array of unlocked keys (single key for individual paths, multiple for group paths like "unlock-all", "ghost-mode").
public func unlockFeature(
userId: Int64,
deeplinkPath: String,
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<[String], SupportersAPIError> {
return encryptedAPICall(
action: "unlock_feature",
payload: ["userId": String(userId), "deeplinkPath": deeplinkPath],
baseURL: baseURL,
aesKey: aesKey,
hmacKey: hmacKey
) |> map { json in
// Group unlock: server returns "unlockedKeys": ["key1", "key2", ...]
if let keys = json["unlockedKeys"] as? [String] {
return keys
}
// Single unlock: server returns "key": "singleKey"
if let key = json["key"] as? String {
return [key]
}
return []
}
}
/// Convenience: fetch gated features using SG_CONFIG.
public func fetchGatedFeaturesIfConfigured() -> Signal<[(key: String, deeplinkPath: String)], SupportersAPIError>? {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
return nil
}
return fetchGatedFeatures(baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey)
}
/// Convenience: fetch unlocked features using SG_CONFIG.
public func fetchUnlockedFeaturesIfConfigured(userId: Int64) -> Signal<[String], SupportersAPIError>? {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
return nil
}
return fetchUnlockedFeatures(userId: userId, baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey)
}
/// Convenience: unlock feature using SG_CONFIG.
public func unlockFeatureIfConfigured(userId: Int64, deeplinkPath: String) -> Signal<[String], SupportersAPIError>? {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
return nil
}
return unlockFeature(userId: userId, deeplinkPath: deeplinkPath, baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey)
}
/// Refresh gated features cache: fetch gated list + unlocked list, save to SimpleSettings.
public func refreshGatedFeaturesCache(userId: Int64) {
guard let gatedSignal = fetchGatedFeaturesIfConfigured() else { return }
SGLogger.shared.log("SGSupporters", "refreshGatedFeatures: starting")
_ = gatedSignal.start(next: { features in
DispatchQueue.main.async {
SGSimpleSettings.shared.updateGatedFeatures(features)
}
SGLogger.shared.log("SGSupporters", "refreshGatedFeatures: \(features.count) gated features cached")
// Now fetch unlocked for this user
guard let unlockedSignal = fetchUnlockedFeaturesIfConfigured(userId: userId) else { return }
_ = unlockedSignal.start(next: { keys in
DispatchQueue.main.async {
var current = SGSimpleSettings.shared.unlockedFeatureKeys
for k in keys {
if !current.contains(k) { current.append(k) }
}
SGSimpleSettings.shared.unlockedFeatureKeys = current
}
SGLogger.shared.log("SGSupporters", "refreshGatedFeatures: \(keys.count) unlocked features synced")
}, error: { err in
SGLogger.shared.log("SGSupporters", "refreshGatedFeatures: unlocked fetch error — \(err)")
})
}, error: { err in
SGLogger.shared.log("SGSupporters", "refreshGatedFeatures: gated fetch error — \(err)")
})
}
// MARK: - UIColor from hex
private extension UIColor {
convenience init?(hex: String) {
var hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
if hex.hasPrefix("#") { hex = String(hex.dropFirst()) }
if hex.count == 6 {
hex += "FF"
} else if hex.count != 8 {
return nil
}
guard let value = UInt32(hex, radix: 16) else { return nil }
let r = CGFloat((value >> 24) & 0xFF) / 255
let g = CGFloat((value >> 16) & 0xFF) / 255
let b = CGFloat((value >> 8) & 0xFF) / 255
let a = CGFloat(value & 0xFF) / 255
self.init(red: r, green: g, blue: b, alpha: a)
}
}
+213
View File
@@ -0,0 +1,213 @@
import Foundation
import CryptoKit
import SGLogging
private let HMAC_SALT = "glegram-hmac-v1"
private let TS_MAX_AGE_SEC = 300
/// AES-256-GCM + HMAC-SHA256 (anti-tampering, replay protection).
enum SupportersCrypto {
private static let ivLength = 12
private static let authTagLength = 16
/// Normalize key to 32 bytes: base64 decode if 32 bytes, else SHA256 of string.
static func normalizeKeyData(_ key: String) -> Data {
if let decoded = Data(base64Encoded: key), decoded.count == 32 {
return decoded
}
let hash = SHA256.hash(data: Data(Array(key.utf8)))
let bytes = hash.withUnsafeBytes { Array($0) }
return Data(bytes)
}
private static func normalizeKey(_ key: String) -> SymmetricKey {
SymmetricKey(data: normalizeKeyData(key))
}
/// Derive HMAC key: HMAC-SHA256(master_key, "glegram-hmac-v1").
private static func deriveHmacKey(from masterKey: Data) -> SymmetricKey {
let key = SymmetricKey(data: masterKey)
let salt = Data(Array(HMAC_SALT.utf8))
let authCode = HMAC<SHA256>.authenticationCode(for: salt, using: key)
let bytes = authCode.withUnsafeBytes { Array($0) }
return SymmetricKey(data: Data(bytes))
}
/// Resolve HMAC key: if explicit key provided (32-byte base64), use it; else derive from aesKey.
private static func resolveHmacKey(aesKey: String, explicitHmacKey: String?) -> SymmetricKey {
if let hmac = explicitHmacKey, !hmac.isEmpty,
let decoded = Data(base64Encoded: hmac), decoded.count == 32 {
return SymmetricKey(data: decoded)
}
return deriveHmacKey(from: normalizeKeyData(aesKey))
}
/// Canonical JSON: keys sorted alphabetically, optionally exclude "hmac" for signing.
private static func canonicalJSON(_ obj: Any, excludeHmac: Bool = true) -> String {
if let dict = obj as? [String: Any] {
let filtered = excludeHmac ? dict.filter { $0.key != "hmac" } : dict
let sorted = filtered.sorted { $0.key < $1.key }
let parts = sorted.map { jsonEncodeString($0.key) + ":" + canonicalJSON($0.value) }
return "{\(parts.joined(separator: ","))}"
}
if let arr = obj as? [Any] {
return "[\(arr.map { canonicalJSON($0) }.joined(separator: ","))]"
}
return jsonEncodePrimitive(obj)
}
/// Manual JSON string encoding to avoid JSONSerialization.data (crashes with NSData dataWithBytesNoCopy on iOS 16).
private static func jsonEncodeString(_ s: String) -> String {
let escaped = s
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\u{0008}", with: "\\b")
.replacingOccurrences(of: "\u{000C}", with: "\\f")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
.replacingOccurrences(of: "\t", with: "\\t")
return "\"\(escaped)\""
}
/// Manual JSON primitive encoding to avoid JSONSerialization.data (crashes with NSData dataWithBytesNoCopy on iOS 16).
private static func jsonEncodePrimitive(_ val: Any) -> String {
switch val {
case is NSNull:
return "null"
case let b as Bool:
return b ? "true" : "false"
case let i as Int:
return String(i)
case let i as Int8:
return String(i)
case let i as Int16:
return String(i)
case let i as Int32:
return String(i)
case let i as Int64:
return String(i)
case let i as UInt:
return String(i)
case let i as UInt8:
return String(i)
case let i as UInt16:
return String(i)
case let i as UInt32:
return String(i)
case let i as UInt64:
return String(i)
case let d as Double:
return d.isFinite ? String(d) : "null"
case let f as Float:
return f.isFinite ? String(f) : "null"
case let s as String:
return jsonEncodeString(s)
default:
return "\"\""
}
}
/// Compute HMAC-SHA256 of canonical JSON, base64 result.
private static func computeHmac(_ obj: [String: Any], aesKey: String, explicitHmacKey: String?) -> String {
let hmacKey = resolveHmacKey(aesKey: aesKey, explicitHmacKey: explicitHmacKey)
let canonical = canonicalJSON(obj)
let data = Data(Array(canonical.utf8))
let authCode = HMAC<SHA256>.authenticationCode(for: data, using: hmacKey)
let bytes = authCode.withUnsafeBytes { Array($0) }
let result = Data(bytes).base64EncodedString()
SGLogger.shared.log("SGSupporters.HMAC", "computeHmac: canonicalLen=\(canonical.count), resultLen=\(result.count), explicitKey=\(explicitHmacKey != nil)")
return result
}
/// Add ts and hmac to payload before encryption.
private static func signPayload(_ obj: inout [String: Any], aesKey: String, explicitHmacKey: String?) {
obj["ts"] = Int(Date().timeIntervalSince1970)
obj["hmac"] = computeHmac(obj, aesKey: aesKey, explicitHmacKey: explicitHmacKey)
SGLogger.shared.log("SGSupporters.HMAC", "signPayload: ts=\(obj["ts"] ?? "?"), keys=\(obj.keys.sorted().joined(separator: ","))")
}
/// Constant-time HMAC comparison to prevent timing attacks.
private static func secureCompare(_ a: String, _ b: String) -> Bool {
guard let aData = Data(base64Encoded: a), let bData = Data(base64Encoded: b) else {
return false
}
guard aData.count == bData.count else { return false }
var result: UInt8 = 0
for i in 0..<aData.count {
result |= aData[i] ^ bData[i]
}
return result == 0
}
/// Verify ts (±5 min) and hmac. Throws if invalid.
private static func verifySignedPayload(_ obj: [String: Any], aesKey: String, explicitHmacKey: String?) throws {
guard let receivedHmac = obj["hmac"] as? String else {
SGLogger.shared.log("SGSupporters.HMAC", "verifySignedPayload: missing hmac")
throw SupportersCryptoError.invalidPayload
}
var copy = obj
copy.removeValue(forKey: "hmac")
let expected = computeHmac(copy, aesKey: aesKey, explicitHmacKey: explicitHmacKey)
guard secureCompare(receivedHmac, expected) else {
SGLogger.shared.log("SGSupporters.HMAC", "verifySignedPayload: HMAC mismatch (receivedLen=\(receivedHmac.count), expectedLen=\(expected.count))")
throw SupportersCryptoError.invalidPayload
}
guard let ts = obj["ts"] as? Int else {
SGLogger.shared.log("SGSupporters.HMAC", "verifySignedPayload: missing ts")
throw SupportersCryptoError.invalidPayload
}
let now = Int(Date().timeIntervalSince1970)
guard abs(now - ts) <= TS_MAX_AGE_SEC else {
SGLogger.shared.log("SGSupporters.HMAC", "verifySignedPayload: ts expired (ts=\(ts), now=\(now), diff=\(abs(now - ts)))")
throw SupportersCryptoError.invalidPayload
}
SGLogger.shared.log("SGSupporters.HMAC", "verifySignedPayload: ok ts=\(ts)")
}
/// Safe JSON encoding to Data, avoids JSONSerialization.data (crashes on iOS 16+).
public static func jsonData(from obj: Any) -> Data {
Data(Array(canonicalJSON(obj, excludeHmac: false).utf8))
}
/// Encrypt payload with ts + hmac. Server format: IV (12) + authTag (16) + ciphertext.
/// Uses manual JSON encoding to avoid JSONSerialization.data (crashes with NSData dataWithBytesNoCopy on iOS 16+).
static func encrypt(_ payload: [String: Any], key: String, hmacKey: String? = nil) throws -> String {
var obj = payload
signPayload(&obj, aesKey: key, explicitHmacKey: hmacKey)
let keyMaterial = normalizeKey(key)
let plaintext = Data(Array(canonicalJSON(obj, excludeHmac: false).utf8))
let nonce = AES.GCM.Nonce()
let sealed = try AES.GCM.seal(plaintext, using: keyMaterial, nonce: nonce)
guard let combined = sealed.combined, combined.count >= ivLength + authTagLength else {
throw SupportersCryptoError.sealFailed
}
let noncePart = Array(combined.prefix(ivLength))
let tagPart = Array(combined.suffix(authTagLength))
let cipherPart = Array(combined.dropFirst(ivLength).dropLast(authTagLength))
let forServer = Data(noncePart) + Data(tagPart) + Data(cipherPart)
return forServer.base64EncodedString()
}
/// Decrypt and verify ts + hmac. Throws if invalid or expired.
static func decrypt(_ base64Payload: String, key: String, hmacKey: String? = nil) throws -> [String: Any] {
guard let raw = Data(base64Encoded: base64Payload), raw.count > ivLength + authTagLength else {
throw SupportersCryptoError.invalidPayload
}
let iv = Array(raw.prefix(ivLength))
let tag = raw.subdata(in: ivLength..<(ivLength + authTagLength))
let ciphertext = Array(raw.suffix(from: ivLength + authTagLength))
let combinedForCryptoKit = Data(iv) + Data(ciphertext) + Data(tag)
let keyMaterial = normalizeKey(key)
let sealed = try AES.GCM.SealedBox(combined: combinedForCryptoKit)
let decrypted = try AES.GCM.open(sealed, using: keyMaterial)
let json = try JSONSerialization.jsonObject(with: decrypted) as? [String: Any]
guard let json = json else { throw SupportersCryptoError.invalidPayload }
try verifySignedPayload(json, aesKey: key, explicitHmacKey: hmacKey)
return json
}
}
enum SupportersCryptoError: Error {
case sealFailed
case invalidPayload
}
@@ -0,0 +1,278 @@
import Foundation
import MachO
import CryptoKit
import SGLogging
// MARK: - Runtime integrity verification
//
// Three independent layers that must ALL pass for access to be granted:
// 1. Text-segment checksum detects ANY patched byte in __TEXT,__text
// 2. Anti-stub detection recognises common crack patterns (mov w0,#1;ret, nop, b+N)
// 3. Accumulator-based access access derived from a sum of fragments contributed by
// multiple independent code paths; patching any single path breaks the total
//
// The access check is NOT a single "if bool" it is the result of XOR-ing
// multiple fragment values. The expected XOR depends on which access flags
// the server granted, making it impossible to bypass by patching one comparison.
public enum SupportersIntegrity {
//
// 1. __TEXT,__text checksum
//
private static var _sealedHash: UInt64 = 0
private static var _sealed = false
/// Sample ~512 points across the main executable's __TEXT,__text section
/// and compute a rolling hash. Any single patched instruction changes the result.
@inline(never)
public static func textChecksum() -> UInt64 {
guard let header = _dyld_get_image_header(0) else { return 0 }
var size: UInt = 0
let h64 = UnsafeRawPointer(header).assumingMemoryBound(to: mach_header_64.self)
guard let ptr = getsectiondata(h64, "__TEXT", "__text", &size), size > 64 else { return 0 }
var h: UInt64 = 0x736F6D65_70736575 // SipHash-like seed
let step = max(8, Int(size) / 512)
var off = 0
while off + 8 <= Int(size) {
let v = ptr.advanced(by: off)
.withMemoryRebound(to: UInt64.self, capacity: 1) { $0.pointee }
h ^= v
h &*= 0x517CC1B7_27220A95
h = (h << 13) | (h >> 51)
off += step
}
return h
}
/// Call once after the first successful encrypted API validation in a session.
/// Records the "known-good" checksum for later comparison.
public static func seal() {
guard !_sealed else { return }
_sealedHash = textChecksum()
_sealed = true
SGLogger.shared.log("SGIntegrity", "sealed text checksum")
}
/// Returns `false` if __TEXT has been modified after sealing (= runtime patch).
/// If not yet sealed, returns `true` (don't block cold start from cache).
@inline(__always)
public static func textOK() -> Bool {
guard _sealed else { return true }
return textChecksum() == _sealedHash
}
//
// 2. Anti-stub detection
//
/// Recognises ARM64 instruction patterns commonly injected by crackers.
///
/// Covered patterns (little-endian):
/// - `mov wN, #imm16; ret` (0x5280xxxx 0xD65F03C0)
/// - `nop; nop` (0xD503201F 0xD503201F)
/// - `b +N` first instr (0x14xxxxxx)
@inline(__always)
public static func isStubbed(_ ptr: UnsafeRawPointer) -> Bool {
let pair = ptr.load(as: UInt64.self)
// second instruction == ret?
let hi32 = UInt32(truncatingIfNeeded: pair >> 32)
if hi32 == 0xD65F03C0 { // ret
let lo32 = UInt32(truncatingIfNeeded: pair)
if lo32 & 0xFF800000 == 0x52800000 { // mov wN, #imm16
return true
}
}
// nop; nop
if pair == 0xD503201F_D503201F { return true }
// unconditional branch as first instruction
let first = UInt32(truncatingIfNeeded: pair)
if first & 0xFC000000 == 0x14000000 { return true }
return false
}
//
// 3. Accumulator / fragment-based access derivation
//
// Design:
// Several independent code paths each contribute a deterministic "fragment".
// Fragments are XOR'd together; the final value is compared to an "expected"
// that encodes the actual access flags.
//
// expected = F_crypto ^ F_cache ^ F_text ^ F_access(flags)
//
// If any code path was stubbed (returns early / wrong value), its fragment
// will differ and the final XOR won't match access denied.
//
// The "expected" value is recomputed from the stored per-user seed, so the
// cracker cannot hard-code it it changes per user/session.
// Fixed fragment keys (compile-time constants, deliberately non-round).
// These are combined with runtime data to produce the actual fragments.
public static let kCryptoOK: UInt64 = 0xA3B7_C2D1_E5F6_0718
public static let kCacheOK: UInt64 = 0x4E5F_6A7B_8C9D_0E1F
public static let kTextOK: UInt64 = 0x1C2D_3E4F_5A6B_7C8D
public static let kAccessOn: UInt64 = 0x9F8E_7D6C_5B4A_3928
public static let kBetaOn: UInt64 = 0x72F1_843A_6BD5_9EC0
private static var _fragments = [UInt64]()
private static let _lock = NSLock()
/// Reset at the beginning of a new validation cycle.
public static func resetFragments() {
_lock.lock()
_fragments.removeAll()
_lock.unlock()
}
/// A verified code path contributes its fragment.
@inline(__always)
public static func contribute(_ fragment: UInt64) {
_lock.lock()
_fragments.append(fragment)
_lock.unlock()
}
/// XOR of all contributed fragments.
@inline(__always)
public static func accumulatedXOR() -> UInt64 {
_lock.lock()
let v = _fragments.reduce(0 as UInt64) { $0 ^ $1 }
_lock.unlock()
return v
}
/// Number of contributed fragments (sanity: must be expected count).
@inline(__always)
public static func fragmentCount() -> Int {
_lock.lock()
let c = _fragments.count
_lock.unlock()
return c
}
//
// 4. Composite access derivation
//
/// Derive glegramTab access from accumulated fragments.
///
/// Expected XOR when all integrity checks pass AND glegramTab == true:
/// kCryptoOK ^ kCacheOK ^ kTextOK ^ kAccessOn
///
/// If any layer is missing or returns the wrong fragment, the XOR differs
/// from the expected value access denied.
///
/// This function is `@inline(__always)` so it is duplicated at every call site.
/// The cracker must patch EVERY call site, not just one function.
@inline(__always)
public static func deriveGlegramTab() -> Bool {
let expected = kCryptoOK ^ kCacheOK ^ kTextOK ^ kAccessOn
return accumulatedXOR() == expected && fragmentCount() >= 3
}
/// Same for betaBuilds uses a different expected value.
@inline(__always)
public static func deriveBetaBuilds() -> Bool {
let expected = kCryptoOK ^ kCacheOK ^ kTextOK ^ kBetaOn
return accumulatedXOR() == expected && fragmentCount() >= 3
}
//
// 5. Per-user access token (HMAC-signed)
//
/// Compute a per-user access token: HMAC-SHA256(derivedKey, userId|flags).
/// The server can also generate this token so the client can verify it.
public static func computeAccessToken(
userId: String,
glegramTab: Bool,
betaBuilds: Bool,
hmacKeyData: Data
) -> Data {
let integrityKey = deriveIntegrityKey(from: hmacKeyData)
let payload = "\(userId)|\(glegramTab ? "1" : "0")|\(betaBuilds ? "1" : "0")"
let auth = HMAC<SHA256>.authenticationCode(
for: Data(payload.utf8),
using: SymmetricKey(data: integrityKey)
)
return Data(auth)
}
/// Verify a stored access token against recomputed expected value.
@inline(__always)
public static func verifyAccessToken(
_ token: Data,
userId: String,
glegramTab: Bool,
betaBuilds: Bool,
hmacKeyData: Data
) -> Bool {
let expected = computeAccessToken(
userId: userId,
glegramTab: glegramTab,
betaBuilds: betaBuilds,
hmacKeyData: hmacKeyData
)
guard expected.count == token.count, !token.isEmpty else { return false }
var diff: UInt8 = 0
for i in 0..<expected.count {
diff |= expected[i] ^ token[i]
}
return diff == 0
}
/// Derive a separate key for access tokens (so it differs from the API HMAC key).
private static func deriveIntegrityKey(from masterKey: Data) -> Data {
let salt = Data("glegram-integrity-v1".utf8)
let auth = HMAC<SHA256>.authenticationCode(for: salt, using: SymmetricKey(data: masterKey))
return Data(auth)
}
//
// 6. Full validation entry point
//
/// Run all integrity layers and contribute their fragments to the accumulator.
/// Call this during / after encrypted API validation.
///
/// - `cryptoSucceeded`: set `true` after a successful decrypt+HMAC verify
/// - `cacheDecrypted`: set `true` after Keychain data was decrypted successfully
/// - `glegramTab` / `betaBuilds`: raw access flags from the server response
///
/// After calling this, `deriveGlegramTab()` / `deriveBetaBuilds()` return
/// the integrity-verified access.
public static func validate(
cryptoSucceeded: Bool,
cacheDecrypted: Bool,
glegramTab: Bool,
betaBuilds: Bool
) {
resetFragments()
// Fragment 1: crypto verification passed
if cryptoSucceeded {
contribute(kCryptoOK)
}
// Fragment 2: cache decryption passed
if cacheDecrypted {
contribute(kCacheOK)
}
// Fragment 3: text segment not patched
if textOK() {
contribute(kTextOK)
}
// Fragment 4: access flag (determines which derive* returns true)
if glegramTab {
contribute(kAccessOn)
} else if betaBuilds {
contribute(kBetaOn)
}
}
}
@@ -0,0 +1,97 @@
import Foundation
import Security
import CryptoKit
/// Key derivation for local encryption (key not stored derived from app identity).
private func deriveLocalKey() -> SymmetricKey {
let salt = "sg_local_v1_7f3a9e"
let seed = (Bundle.main.bundleIdentifier ?? "sg") + salt
let hash = SHA256.hash(data: Data(Array(seed.utf8)))
return SymmetricKey(data: hash)
}
/// AES-256-GCM encrypt before Keychain write. Protects against Keychain-substitution tweaks.
private func encryptForStorage(_ plaintext: Data) -> Data? {
let key = deriveLocalKey()
let nonce = AES.GCM.Nonce()
guard let sealed = try? AES.GCM.seal(plaintext, using: key, nonce: nonce),
let combined = sealed.combined else { return nil }
return combined
}
/// AES-256-GCM decrypt after Keychain read.
private func decryptFromStorage(_ ciphertext: Data) -> Data? {
let key = deriveLocalKey()
guard let sealed = try? AES.GCM.SealedBox(combined: ciphertext),
let decrypted = try? AES.GCM.open(sealed, using: key) else { return nil }
return decrypted
}
/// Keychain + AES-256-GCM. Data is encrypted before Keychain; substitution tweaks get ciphertext only.
private enum SupportersSecureStorage {
private static let service = "sg_glegram_secure"
private static let accessibility = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
static func getData(account: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let ciphertext = result as? Data else {
return nil
}
return decryptFromStorage(ciphertext)
}
static func setData(_ plaintext: Data, account: String) -> Bool {
guard let ciphertext = encryptForStorage(plaintext) else { return false }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessible as String: accessibility
]
var status = SecItemCopyMatching(query as CFDictionary, nil)
if status == errSecSuccess {
let updateQuery: [String: Any] = [kSecValueData as String: ciphertext]
status = SecItemUpdate(query as CFDictionary, updateQuery as CFDictionary)
return status == errSecSuccess
} else if status == errSecItemNotFound {
var addQuery = query
addQuery[kSecValueData as String] = ciphertext
status = SecItemAdd(addQuery as CFDictionary, nil)
return status == errSecSuccess
}
return false
}
static func delete(account: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
}
func supportersSecureLoadJSON(account: String) -> [String: Any]? {
guard let data = SupportersSecureStorage.getData(account: account),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
return json
}
func supportersSecureSaveJSON(_ dict: [String: Any], account: String) -> Bool {
guard let data = try? JSONSerialization.data(withJSONObject: dict) else {
return false
}
return SupportersSecureStorage.setData(data, account: account)
}
@@ -0,0 +1,52 @@
import Foundation
/// URL validation for supporters API responses. Prevents injection (javascript:, file:, etc.) and SSRF.
private let allowedExternalSchemes = ["http", "https", "tg"]
private let blockedHosts: Set<String> = [
"localhost", "127.0.0.1", "0.0.0.0",
"169.254.169.254", // cloud metadata
"metadata.google.internal",
"::1"
]
/// Returns true if URL is safe to open externally (badge buttons, miniAppUrl, beta links).
/// Only allows http, https, tg schemes. Blocks javascript:, file:, data:, etc.
public func isUrlSafeForExternalOpen(_ urlString: String) -> Bool {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
guard let url = URL(string: trimmed) else { return false }
guard let scheme = url.scheme?.lowercased() else { return false }
guard allowedExternalSchemes.contains(scheme) else { return false }
if let host = url.host?.lowercased(), blockedHosts.contains(host) {
return false
}
return true
}
/// Returns true if URL is safe to fetch as badge image. Prevents SSRF.
/// Only allows http/https to same origin (supportersApiUrl) or explicitly allowed hosts.
public func isUrlSafeForBadgeImage(_ urlString: String, allowedBaseURL: String?) -> Bool {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if trimmed.hasPrefix("/") {
guard let base = allowedBaseURL else { return false }
let baseNorm = base.hasSuffix("/") ? String(base.dropLast()) : base
guard let baseUrl = URL(string: baseNorm), let host = baseUrl.host else { return false }
return !blockedHosts.contains(host.lowercased())
}
guard let url = URL(string: trimmed),
let scheme = url.scheme?.lowercased(),
(scheme == "http" || scheme == "https"),
let host = url.host?.lowercased() else {
return false
}
guard !blockedHosts.contains(host) else { return false }
if host == "127.0.0.1" || host.hasPrefix("192.168.") || host.hasPrefix("10.") || host.hasPrefix("172.") {
return false
}
if let base = allowedBaseURL, let baseUrl = URL(string: base), let baseHost = baseUrl.host?.lowercased() {
return host == baseHost || host.hasSuffix("." + baseHost)
}
return true
}
+20
View File
@@ -0,0 +1,20 @@
# Tor removed to reduce IPA size (~70 MB). Stub only; no Tor.framework.
load("@rules_cc//cc:defs.bzl", "objc_library")
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
objc_library(
name = "TorEmbeddedRunner",
module_name = "TorEmbeddedRunner",
srcs = ["Sources/TorEmbeddedRunner.m"],
hdrs = ["Sources/TorEmbeddedRunner.h"],
copts = ["-fobjc-arc"],
visibility = ["//visibility:public"],
)
swift_library(
name = "TorEmbedded",
module_name = "TorEmbedded",
srcs = ["Sources/TorEmbedded.swift"],
deps = [":TorEmbeddedRunner"],
visibility = ["//visibility:public"],
)
+32
View File
@@ -0,0 +1,32 @@
// MARK: Swiftgram Tor removed to reduce IPA size. Stub only.
import Foundation
import TorEmbeddedRunner
public enum TorEmbedded {
/// Log lines from last Tor startup (when "Show Tor startup logs" is on). Updated on main queue.
private static let _logLinesLock = NSLock()
private static var _logLines: [String] = []
public static var logLines: [String] {
_logLinesLock.lock()
defer { _logLinesLock.unlock() }
return _logLines
}
/// Posted when a new log line is appended (object = line String). Subscribe to refresh Tor logs UI.
public static let didAppendLogNotification = Notification.Name("TorEmbedded.didAppendLog")
/// Tor removed to reduce IPA size. No-op.
public static func startIfNeeded() {
return
}
/// Stop Tor (e.g. when user disables "Use Tor in browser").
public static func stop() {
TorEmbeddedRunner.stop()
}
/// Whether Tor is running and circuit is established (browser can use SOCKS 9050).
public static var isReady: Bool {
TorEmbeddedRunner.isReady()
}
}
+22
View File
@@ -0,0 +1,22 @@
// MARK: Swiftgram — Built-in Tor runner (SOCKS 9050, control 9051). Only browser traffic uses Tor; Telegram API is unchanged.
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface TorEmbeddedRunner : NSObject
/// Start Tor in-process. Bridges string: one bridge per line (e.g. "obfs4 1.2.3.4:443 fingerprint" or meek URL). Pass nil or empty for no bridges.
+ (void)startWithBridges:(nullable NSString *)bridges;
/// Stop Tor.
+ (void)stop;
/// Whether Tor is running and circuit is established (SOCKS on 9050, control on 9051).
+ (BOOL)isReady;
/// Optional. When set, startup log messages are reported (e.g. "Starting Tor...", "Circuit established."). Call from main or background; handler may be invoked on any queue.
+ (void)setLogCallback:(void (^ _Nullable)(NSString * _Nonnull message))callback;
@end
NS_ASSUME_NONNULL_END
+21
View File
@@ -0,0 +1,21 @@
// Tor removed to reduce IPA size. No-op stub; no Tor.framework linked.
#import "TorEmbeddedRunner.h"
@implementation TorEmbeddedRunner
+ (void)setLogCallback:(void (^)(NSString * _Nonnull))callback {
(void)callback;
}
+ (void)startWithBridges:(NSString *)bridges {
(void)bridges;
}
+ (void)stop {
}
+ (BOOL)isReady {
return NO;
}
@end
+13
View File
@@ -0,0 +1,13 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "VoiceMorpher",
module_name = "VoiceMorpher",
srcs = glob(["Sources/**/*.swift"]),
copts = [
"-warnings-as-errors",
],
deps = [
],
visibility = ["//visibility:public"],
)
@@ -0,0 +1,45 @@
import Foundation
// MARK: - GLEGram
/// Local OGG voice morphing (ghostgram-style): decode -> AVAudioEngine effects -> encode.
/// NOTE: Requires OpusBinding with VoiceMorpherProcessor. See VoiceMorpherProcessor.h/.m in OpusBinding.
public final class VoiceMorpherEngine {
public static let shared = VoiceMorpherEngine()
private init() {}
/// Process OGG audio data with the currently selected voice morpher preset.
/// Calls VoiceMorpherProcessor from OpusBinding for actual audio processing.
/// When OpusBinding is not available, returns the input data unmodified.
public func processOggData(
_ inputData: Data,
completion: @escaping (Swift.Result<Data, Error>) -> Void
) {
let preset = VoiceMorpherManager.shared.effectivePreset
guard preset != .disabled else {
completion(.success(inputData))
return
}
// NOTE: Full integration requires OpusBinding with VoiceMorpherProcessor.
// The Objective-C VoiceMorpherProcessor decodes OGG -> applies AVAudioEngine effects -> re-encodes.
// When that bridge is wired:
// VoiceMorpherProcessor.processOggData(inputData, preset: objcPreset) { outputData, error in ... }
//
// For now, pass through unmodified as a stub.
completion(.success(inputData))
}
public enum VoiceMorpherError: Error, LocalizedError {
case processingFailed
public var errorDescription: String? {
switch self {
case .processingFailed:
return "Voice morphing processing failed"
}
}
}
}
@@ -0,0 +1,91 @@
import Foundation
// MARK: - GLEGram
/// GLEGram / ghostgram-style: local voice morphing for outgoing voice messages (UserDefaults).
public final class VoiceMorpherManager {
public static let shared = VoiceMorpherManager()
public enum VoicePreset: Int, CaseIterable {
case disabled = 0
case anonymous = 1
case female = 2
case male = 3
case child = 4
case robot = 5
public func title(langIsRu: Bool) -> String {
switch self {
case .disabled:
return langIsRu ? "Выключено" : "Off"
case .anonymous:
return langIsRu ? "Аноним" : "Anonymous"
case .female:
return langIsRu ? "Женский" : "Female"
case .male:
return langIsRu ? "Мужской" : "Male"
case .child:
return langIsRu ? "Ребёнок" : "Child"
case .robot:
return langIsRu ? "Робот" : "Robot"
}
}
public func subtitle(langIsRu: Bool) -> String {
switch self {
case .disabled:
return langIsRu ? "Без изменений" : "Unchanged"
case .anonymous:
return langIsRu ? "Искажённый голос" : "Distorted voice"
case .female:
return langIsRu ? "Выше тон" : "Higher pitch"
case .male:
return langIsRu ? "Ниже тон" : "Lower pitch"
case .child:
return langIsRu ? "Детский тон" : "Child-like"
case .robot:
return langIsRu ? "Металлический эффект" : "Metallic effect"
}
}
}
private enum Keys {
static let isEnabled = "VoiceMorpher.isEnabled"
static let selectedPreset = "VoiceMorpher.selectedPreset"
}
private let defaults = UserDefaults.standard
public var isEnabled: Bool {
get { defaults.bool(forKey: Keys.isEnabled) }
set {
defaults.set(newValue, forKey: Keys.isEnabled)
notifyChanged()
}
}
public var selectedPresetId: Int {
get { defaults.integer(forKey: Keys.selectedPreset) }
set {
defaults.set(newValue, forKey: Keys.selectedPreset)
notifyChanged()
}
}
public var selectedPreset: VoicePreset {
VoicePreset(rawValue: selectedPresetId) ?? .disabled
}
public var effectivePreset: VoicePreset {
guard isEnabled else { return .disabled }
return selectedPreset
}
public static let settingsChangedNotification = Notification.Name("VoiceMorpherSettingsChanged")
private func notifyChanged() {
NotificationCenter.default.post(name: Self.settingsChangedNotification, object: nil)
}
private init() {}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+80
View File
@@ -0,0 +1,80 @@
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
# MARK: Swiftgram
new_git_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:git.bzl", "new_git_repository")
bazel_dep(name = "bazel_features", version = "1.33.0")
bazel_dep(name = "bazel_skylib", version = "1.8.1")
bazel_dep(name = "platforms", version = "0.0.11")
bazel_dep(name = "rules_xcodeproj")
local_path_override(
module_name = "rules_xcodeproj",
path = "./build-system/bazel-rules/rules_xcodeproj",
)
bazel_dep(name = "rules_apple", repo_name = "build_bazel_rules_apple")
local_path_override(
module_name = "rules_apple",
path = "./build-system/bazel-rules/rules_apple",
)
bazel_dep(name = "rules_swift", repo_name = "build_bazel_rules_swift")
local_path_override(
module_name = "rules_swift",
path = "./build-system/bazel-rules/rules_swift",
)
bazel_dep(name = "apple_support", repo_name = "build_bazel_apple_support")
local_path_override(
module_name = "apple_support",
path = "./build-system/bazel-rules/apple_support",
)
# MARK: Swiftgram
new_git_repository(
name = "flex_sdk",
remote = "https://github.com/FLEXTool/FLEX.git",
commit = "2bfba6715eff664ef84a02e8eb0ad9b5a609c684",
build_file = "@//Swiftgram/FLEX:FLEX.BUILD"
)
http_file(
name = "cmake_tar_gz",
urls = ["https://github.com/Kitware/CMake/releases/download/v4.1.2/cmake-4.1.2-macos-universal.tar.gz"],
sha256 = "3be85f5b999e327b1ac7d804cbc9acd767059e9f603c42ec2765f6ab68fbd367",
)
http_file(
name = "meson_tar_gz",
urls = ["https://github.com/mesonbuild/meson/releases/download/1.6.0/meson-1.6.0.tar.gz"],
sha256 = "999b65f21c03541cf11365489c1fad22e2418bb0c3d50ca61139f2eec09d5496",
)
http_file(
name = "ninja-mac_zip",
urls = ["https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-mac.zip"],
sha256 = "89a287444b5b3e98f88a945afa50ce937b8ffd1dcc59c555ad9b1baf855298c9",
)
http_file(
name = "flatbuffers_zip",
urls = ["https://github.com/google/flatbuffers/archive/refs/tags/v24.12.23.zip"],
sha256 = "c5cd6a605ff20350c7faa19d8eeb599df6117ea4aabd16ac58a7eb5ba82df4e7",
)
provisioning_profile_repository = use_extension("@build_bazel_rules_apple//apple:apple.bzl", "provisioning_profile_repository_extension")
#provisioning_profile_repository.setup(
# fallback_profiles = "//path/to/some:filegroup", # Profiles to use if one isn't found locally
#)
bazel_dep(name = "build_configuration")
local_path_override(
module_name = "build_configuration",
path = "./build-input/configuration-repository",
)
bazel_dep(name = "sourcekit_bazel_bsp", version = "0.7.1")
local_path_override(
module_name = "sourcekit_bazel_bsp",
path = "build-system/bazel-rules/sourcekit-bazel-bsp",
)
+320
View File
@@ -0,0 +1,320 @@
{
"lockFileVersion": 18,
"registryFileHashes": {
"https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497",
"https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2",
"https://bcr.bazel.build/modules/abseil-cpp/20211102.0/MODULE.bazel": "70390338f7a5106231d20620712f7cccb659cd0e9d073d1991c038eb9fc57589",
"https://bcr.bazel.build/modules/abseil-cpp/20230125.1/MODULE.bazel": "89047429cb0207707b2dface14ba7f8df85273d484c2572755be4bab7ce9c3a0",
"https://bcr.bazel.build/modules/abseil-cpp/20230802.0.bcr.1/MODULE.bazel": "1c8cec495288dccd14fdae6e3f95f772c1c91857047a098fad772034264cc8cb",
"https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16",
"https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915",
"https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed",
"https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16",
"https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1",
"https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215",
"https://bcr.bazel.build/modules/abseil-cpp/20250512.1/source.json": "d725d73707d01bb46ab3ca59ba408b8e9bd336642ca77a2269d4bfb8bbfd413d",
"https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
"https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8",
"https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d",
"https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d",
"https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a",
"https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58",
"https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b",
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
"https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
"https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6",
"https://bcr.bazel.build/modules/bazel_features/1.33.0/source.json": "13617db3930328c2cd2807a0f13d52ca870ac05f96db9668655113265147b2a6",
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
"https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
"https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
"https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686",
"https://bcr.bazel.build/modules/bazel_skylib/1.2.1/MODULE.bazel": "f35baf9da0efe45fa3da1696ae906eea3d615ad41e2e3def4aeb4e8bc0ef9a7a",
"https://bcr.bazel.build/modules/bazel_skylib/1.3.0/MODULE.bazel": "20228b92868bf5cfc41bda7afc8a8ba2a543201851de39d990ec957b513579c5",
"https://bcr.bazel.build/modules/bazel_skylib/1.4.1/MODULE.bazel": "a0dcb779424be33100dcae821e9e27e4f2901d9dfd5333efe5ac6a8d7ab75e1d",
"https://bcr.bazel.build/modules/bazel_skylib/1.4.2/MODULE.bazel": "3bd40978e7a1fac911d5989e6b09d8f64921865a45822d8b09e815eaa726a651",
"https://bcr.bazel.build/modules/bazel_skylib/1.5.0/MODULE.bazel": "32880f5e2945ce6a03d1fbd588e9198c0a959bb42297b2cfaf1685b7bc32e138",
"https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/source.json": "7ebaefba0b03efe59cac88ed5bbc67bcf59a3eff33af937345ede2a38b2d368a",
"https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84",
"https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8",
"https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb",
"https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4",
"https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6",
"https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f",
"https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108",
"https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46",
"https://bcr.bazel.build/modules/googletest/1.17.0/source.json": "38e4454b25fc30f15439c0378e57909ab1fd0a443158aa35aec685da727cd713",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075",
"https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0",
"https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000",
"https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902",
"https://bcr.bazel.build/modules/nlohmann_json/3.12.0.bcr.1/MODULE.bazel": "a1c8bb07b5b91d971727c635f449d05623ac9608f6fe4f5f04254ea12f08e349",
"https://bcr.bazel.build/modules/nlohmann_json/3.12.0.bcr.1/source.json": "93f82a5ae985eb935c539bfee95e04767187818189241ac956f3ccadbdb8fb02",
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
"https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f",
"https://bcr.bazel.build/modules/platforms/0.0.11/source.json": "f7e188b79ebedebfe75e9e1d098b8845226c7992b307e28e1496f23112e8fc29",
"https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee",
"https://bcr.bazel.build/modules/platforms/0.0.5/MODULE.bazel": "5733b54ea419d5eaf7997054bb55f6a1d0b5ff8aedf0176fef9eea44f3acda37",
"https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615",
"https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814",
"https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d",
"https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc",
"https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7",
"https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c",
"https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d",
"https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df",
"https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92",
"https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e",
"https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95",
"https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0",
"https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42",
"https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79",
"https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e",
"https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34",
"https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680",
"https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206",
"https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a",
"https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/source.json": "2ff292be6ef3340325ce8a045ecc326e92cbfab47c7cbab4bd85d28971b97ac4",
"https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa",
"https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8",
"https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e",
"https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647",
"https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002",
"https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191",
"https://bcr.bazel.build/modules/rules_cc/0.0.14/MODULE.bazel": "5e343a3aac88b8d7af3b1b6d2093b55c347b8eefc2e7d1442f7a02dc8fea48ac",
"https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc",
"https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87",
"https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a",
"https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c",
"https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f",
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
"https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
"https://bcr.bazel.build/modules/rules_cc/0.2.15/MODULE.bazel": "6a0a4a75a57aa6dc888300d848053a58c6b12a29f89d4304e1c41448514ec6e8",
"https://bcr.bazel.build/modules/rules_cc/0.2.15/source.json": "197965c6dcca5c98a9288f93849e2e1c69d622e71b0be8deb524e22d48c88e32",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
"https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8",
"https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74",
"https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86",
"https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39",
"https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6",
"https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31",
"https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a",
"https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6",
"https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab",
"https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2",
"https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe",
"https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615",
"https://bcr.bazel.build/modules/rules_java/8.14.0/source.json": "8a88c4ca9e8759da53cddc88123880565c520503321e2566b4e33d0287a3d4bc",
"https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017",
"https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939",
"https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2",
"https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7",
"https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909",
"https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036",
"https://bcr.bazel.build/modules/rules_jvm_external/5.3/MODULE.bazel": "bf93870767689637164657731849fb887ad086739bd5d360d90007a581d5527d",
"https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4",
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0",
"https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd",
"https://bcr.bazel.build/modules/rules_jvm_external/6.7/source.json": "5426f412d0a7fc6b611643376c7e4a82dec991491b9ce5cb1cfdd25fe2e92be4",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5",
"https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0",
"https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d",
"https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c",
"https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb",
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a",
"https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06",
"https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7",
"https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483",
"https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73",
"https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2",
"https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96",
"https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e",
"https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f",
"https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300",
"https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382",
"https://bcr.bazel.build/modules/rules_python/0.27.1/MODULE.bazel": "65dc875cc1a06c30d5bbdba7ab021fd9e551a6579e408a3943a61303e2228a53",
"https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed",
"https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58",
"https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937",
"https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c",
"https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7",
"https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13",
"https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8",
"https://bcr.bazel.build/modules/rules_python/1.6.0/source.json": "e980f654cf66ec4928672f41fc66c4102b5ea54286acf4aecd23256c84211be6",
"https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/source.json": "c55ed591aa5009401ddf80ded9762ac32c358d2517ee7820be981e2de9756cf3",
"https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8",
"https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c",
"https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef",
"https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c",
"https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7",
"https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5",
"https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b",
"https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
"https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0",
"https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca",
"https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806",
"https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198"
},
"selectedYankedVersions": {},
"moduleExtensions": {
"@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": {
"general": {
"bzlTransitiveDigest": "OlvsB0HsvxbR8ZN+J9Vf00X/+WVz/Y/5Xrq2LgcVfdo=",
"usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"com_github_jetbrains_kotlin_git": {
"repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository",
"attributes": {
"urls": [
"https://github.com/JetBrains/kotlin/releases/download/v1.9.23/kotlin-compiler-1.9.23.zip"
],
"sha256": "93137d3aab9afa9b27cb06a824c2324195c6b6f6179d8a8653f440f5bd58be88"
}
},
"com_github_jetbrains_kotlin": {
"repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository",
"attributes": {
"git_repository_name": "com_github_jetbrains_kotlin_git",
"compiler_version": "1.9.23"
}
},
"com_github_google_ksp": {
"repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository",
"attributes": {
"urls": [
"https://github.com/google/ksp/releases/download/1.9.23-1.0.20/artifacts.zip"
],
"sha256": "ee0618755913ef7fd6511288a232e8fad24838b9af6ea73972a76e81053c8c2d",
"strip_version": "1.9.23-1.0.20"
}
},
"com_github_pinterest_ktlint": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file",
"attributes": {
"sha256": "01b2e0ef893383a50dbeb13970fe7fa3be36ca3e83259e01649945b09d736985",
"urls": [
"https://github.com/pinterest/ktlint/releases/download/1.3.0/ktlint"
],
"executable": true
}
},
"rules_android": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"sha256": "cd06d15dd8bb59926e4d65f9003bfc20f9da4b2519985c27e190cddc8b7a7806",
"strip_prefix": "rules_android-0.1.1",
"urls": [
"https://github.com/bazelbuild/rules_android/archive/v0.1.1.zip"
]
}
}
},
"recordedRepoMappingEntries": [
[
"rules_kotlin+",
"bazel_tools",
"bazel_tools"
]
]
}
},
"@@rules_python+//python/uv:uv.bzl%uv": {
"general": {
"bzlTransitiveDigest": "PmZM/pIkZKEDDL68TohlKJrWPYKL5VwUw3MA7kmm6fk=",
"usagesDigest": "p80sy6cYQuWxx5jhV3fOTu+N9EyIUFG9+F7UC/nhXic=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"uv": {
"repoRuleId": "@@rules_python+//python/uv/private:uv_toolchains_repo.bzl%uv_toolchains_repo",
"attributes": {
"toolchain_type": "'@@rules_python+//python/uv:uv_toolchain_type'",
"toolchain_names": [
"none"
],
"toolchain_implementations": {
"none": "'@@rules_python+//python:none'"
},
"toolchain_compatible_with": {
"none": [
"@platforms//:incompatible"
]
},
"toolchain_target_settings": {}
}
}
},
"recordedRepoMappingEntries": [
[
"rules_python+",
"bazel_tools",
"bazel_tools"
],
[
"rules_python+",
"platforms",
"platforms"
]
]
}
},
"@@rules_xcodeproj+//xcodeproj:extensions.bzl%internal": {
"general": {
"bzlTransitiveDigest": "+qmLBZzimJ0CYyKoQg6/pbdkTnu/s4e5IisoM+TLM+8=",
"usagesDigest": "fvsnMonVwKDYnBfww4bXuYie3WU0d9VSqT2gePSdQco=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"rules_xcodeproj_generated": {
"repoRuleId": "@@rules_xcodeproj+//xcodeproj:repositories.bzl%generated_files_repo",
"attributes": {}
}
},
"recordedRepoMappingEntries": [
[
"bazel_features+",
"bazel_features_globals",
"bazel_features++version_extension+bazel_features_globals"
],
[
"bazel_features+",
"bazel_features_version",
"bazel_features++version_extension+bazel_features_version"
],
[
"rules_xcodeproj+",
"bazel_features",
"bazel_features+"
],
[
"rules_xcodeproj+",
"bazel_tools",
"bazel_tools"
]
]
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
# GLEGram iOS
GLEGram — a privacy-focused Telegram iOS client based on [Swiftgram](https://github.com/nicegram/nicegram-ios).
## Features
- **Ghost Mode** — Hide online status, typing indicators, read receipts
- **Saved Deleted Messages** — Auto-save messages deleted by others
- **Content Protection Bypass** — Save protected media, disable screenshot detection
- **Font Replacement** — Custom fonts with size control
- **Fake Profile** — Local profile customization
- **Chat Export** — Export chats as HTML/JSON/TXT
- **Plugin System** — JS-based plugin infrastructure
- **And more** — See CHANGELOG_12.5_RU.md for full list
## Build
1. Install Xcode 26.2+ and JDK 21
2. Copy your configuration:
```bash
cp build-system/ipa-build-configuration.json.example build-system/ipa-build-configuration.json
# Edit with your API credentials from https://my.telegram.org/apps
```
3. Build:
```bash
./scripts/buildprod.sh
```
## License
Same as [Telegram iOS](https://github.com/nicegram/nicegram-ios) — GPLv2.
+1
View File
@@ -0,0 +1 @@
06de25b179c80e59
@@ -0,0 +1,9 @@
filegroup(
name = "ChatControllerImplExtension",
srcs = glob([
"Sources/**/*.swift",
]),
visibility = [
"//visibility:public",
],
)

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