Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db4a0b7e6f | |||
| 2e79bbf09a | |||
| d97b22ad5c | |||
| 2f9bf47566 | |||
| 2977873932 | |||
| 3fd1d8e138 | |||
| 6b23b6304c | |||
| 52f78110f6 | |||
| 64c3cb78cc | |||
| d1419bf1a5 | |||
| 0b9992ee30 | |||
| 51c1dc59cf | |||
| 86eaa95019 |
@@ -4,7 +4,7 @@ title: 'bug: '
|
||||
labels:
|
||||
- bug
|
||||
assignees:
|
||||
- SoCuul
|
||||
- faroukbmiled
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -12,7 +12,7 @@ body:
|
||||
<br>
|
||||
|
||||
>[!TIP]
|
||||
> If you are looking for support with the tweak, make sure to visit the [SCInsta discussions page](https://github.com/SoCuul/SCInsta/discussions) to get help.
|
||||
> If you are looking for support with the tweak, make sure to visit the [RyukGram discussions page](https://github.com/faroukbmiled/RyukGram/discussions) to get help.
|
||||
- type: checkboxes
|
||||
id: before-start
|
||||
attributes:
|
||||
@@ -21,13 +21,13 @@ body:
|
||||
options:
|
||||
- label: >-
|
||||
I have read through the
|
||||
[FAQ](https://github.com/SoCuul/SCInsta/wiki/FAQ)
|
||||
[FAQ](https://github.com/faroukbmiled/RyukGram/wiki/FAQ)
|
||||
required: true
|
||||
- label: I have made sure this issue has not already been reported previously
|
||||
required: true
|
||||
- label: >-
|
||||
I have made sure this issue is present in the latest version of
|
||||
SCInsta
|
||||
RyukGram
|
||||
required: true
|
||||
- label: >-
|
||||
I am confident that this bug presents unintended behaviour within
|
||||
@@ -57,9 +57,9 @@ body:
|
||||
attributes:
|
||||
value: '---'
|
||||
- type: input
|
||||
id: info-scinsta-version
|
||||
id: info-ryukgram-version
|
||||
attributes:
|
||||
label: SCInsta Version
|
||||
label: RyukGram Version
|
||||
description: This can be found at the bottom of the tweak settings
|
||||
placeholder: e.g. v0.7.0
|
||||
validations:
|
||||
@@ -76,7 +76,7 @@ body:
|
||||
id: info-install-type
|
||||
attributes:
|
||||
label: Install Type
|
||||
description: The method used to use to install SCInsta
|
||||
description: The method used to use to install RyukGram
|
||||
options:
|
||||
- Sideloaded
|
||||
- TrollStore
|
||||
|
||||
@@ -4,7 +4,7 @@ title: 'feat: '
|
||||
labels:
|
||||
- enhancement
|
||||
assignees:
|
||||
- SoCuul
|
||||
- faroukbmiled
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: before-start
|
||||
@@ -14,7 +14,7 @@ body:
|
||||
options:
|
||||
- label: >-
|
||||
I have read through the
|
||||
[FAQ](https://github.com/SoCuul/SCInsta/wiki/FAQ)
|
||||
[FAQ](https://github.com/faroukbmiled/RyukGram/wiki/FAQ)
|
||||
required: true
|
||||
- label: I have made sure this feature has not already been already suggested
|
||||
required: true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: '💬 Browse Q&A'
|
||||
url: https://github.com/SoCuul/SCInsta/wiki/FAQ
|
||||
url: https://github.com/faroukbmiled/RyukGram/wiki/FAQ
|
||||
about: Find answers to the most commonly asked questions
|
||||
- name: '❓ Need Help?'
|
||||
url: https://github.com/SoCuul/SCInsta/discussions
|
||||
about: Visit the SCInsta discussions form to get support
|
||||
url: https://github.com/faroukbmiled/RyukGram/discussions
|
||||
about: Visit the RyukGram discussions form to get support
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: PR assignment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
@@ -9,6 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: 'Auto-assign PR'
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
# Inspired heavily by the following workflows
|
||||
# https://github.com/arichornlover/uYouEnhanced/blob/main/.github/workflows/buildapp.yml
|
||||
# https://github.com/ISnackable/YTCubePlus/blob/main/.github/workflows/Build.yml
|
||||
# https://github.com/BandarHL/BHTwitter/actions/workflows/build.yml
|
||||
|
||||
name: Build and Package RyukGram
|
||||
|
||||
on:
|
||||
@@ -18,6 +13,11 @@ on:
|
||||
default: true
|
||||
required: false
|
||||
type: boolean
|
||||
build_tipa:
|
||||
description: "Build tipa"
|
||||
default: false
|
||||
required: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -88,7 +88,10 @@ jobs:
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build RyukGram tweak for sideloading (as IPA)
|
||||
- name: Setup FFmpegKit
|
||||
run: cd main && ./scripts/setup-ffmpegkit.sh
|
||||
|
||||
- name: Build sideloaded IPA (rootless deb → cyan inject)
|
||||
run: |
|
||||
pip install --force-reinstall https://github.com/asdfzxcvbn/pyzule-rw/archive/main.zip
|
||||
|
||||
@@ -96,24 +99,30 @@ jobs:
|
||||
curl -Lo ipapatch https://github.com/asdfzxcvbn/ipapatch/releases/download/v2.1.3/ipapatch.macos-arm64
|
||||
chmod +x ipapatch
|
||||
export PATH=.:$PATH
|
||||
ls -la
|
||||
|
||||
./build.sh sideload
|
||||
ls -la packages
|
||||
env:
|
||||
THEOS: ${{ github.workspace }}/theos
|
||||
|
||||
- name: Rename IPA to include version info
|
||||
- name: Rename IPA
|
||||
run: |
|
||||
cd main/packages
|
||||
mv "$(ls -t | head -n1)" "RyukGram_sideloaded_v${VERSION}.ipa"
|
||||
IPA=$(ls -t *.ipa | grep -iv instagram | head -n1)
|
||||
[ -n "$IPA" ] && mv "$IPA" "RyukGram_sideloaded_v${VERSION}.ipa"
|
||||
|
||||
- name: Duplicate as .tipa
|
||||
if: ${{ inputs.build_tipa }}
|
||||
run: |
|
||||
cd main/packages
|
||||
cp "RyukGram_sideloaded_v${VERSION}.ipa" "RyukGram_trollstore_v${VERSION}.tipa"
|
||||
|
||||
- name: Pass package name to upload action
|
||||
id: package_name
|
||||
run: |
|
||||
echo "package=$(ls -t main/packages | head -n1)" >> "$GITHUB_OUTPUT"
|
||||
echo "package=$(ls -t main/packages/RyukGram_sideloaded_v*.ipa | head -n1 | xargs basename)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload Artifact
|
||||
- name: Upload IPA Artifact
|
||||
if: ${{ inputs.upload_artifact }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -121,11 +130,21 @@ jobs:
|
||||
path: ${{ github.workspace }}/main/packages/${{ steps.package_name.outputs.package }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload TIPA Artifact
|
||||
if: ${{ inputs.upload_artifact && inputs.build_tipa }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: RyukGram_trollstore_v${{ steps.version.outputs.version }}
|
||||
path: ${{ github.workspace }}/main/packages/RyukGram_trollstore_v*.tipa
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2.0.6
|
||||
with:
|
||||
name: RyukGram_sideloaded_v${{ steps.version.outputs.version }}
|
||||
files: ${{ github.workspace }}/main/packages/RyukGram_sideloaded_v*.ipa
|
||||
files: |
|
||||
${{ github.workspace }}/main/packages/RyukGram_sideloaded_v*.ipa
|
||||
${{ github.workspace }}/main/packages/RyukGram_trollstore_v*.tipa
|
||||
draft: true
|
||||
|
||||
- name: Output Release URL
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build RyukGram tweak for Rootless
|
||||
name: Build RyukGram tweak (rootless + rootful)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,7 +12,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
build:
|
||||
name: Build RyukGram Rootless
|
||||
name: Build RyukGram
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -65,28 +65,41 @@ jobs:
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build RyukGram tweak for Rootless
|
||||
- name: Setup FFmpegKit
|
||||
run: cd main && ./scripts/setup-ffmpegkit.sh
|
||||
|
||||
- name: Build rootless
|
||||
run: |
|
||||
cd main
|
||||
ls -la
|
||||
./build.sh rootless
|
||||
ls -la packages
|
||||
cd packages
|
||||
DEB="$(ls -t *-rootless.deb | head -n1)"
|
||||
[ -n "$DEB" ] && mv "$DEB" "com.faroukbmiled.ryukgram_${VERSION}+debug-rootless.deb"
|
||||
ls -la
|
||||
env:
|
||||
THEOS: ${{ github.workspace }}/theos
|
||||
|
||||
- name: Rename deb to include version info
|
||||
- name: Build rootful
|
||||
run: |
|
||||
cd main/packages
|
||||
mv "$(ls -t | head -n1)" "com.faroukbmiled.ryukgram_${VERSION}+debug-rootless.deb"
|
||||
cd main
|
||||
./build.sh rootful
|
||||
cd packages
|
||||
DEB="$(ls -t *-rootful.deb | head -n1)"
|
||||
[ -n "$DEB" ] && mv "$DEB" "com.faroukbmiled.ryukgram_${VERSION}+debug-rootful.deb"
|
||||
ls -la
|
||||
env:
|
||||
THEOS: ${{ github.workspace }}/theos
|
||||
|
||||
- name: Pass package name to upload action
|
||||
id: package_name
|
||||
run: |
|
||||
echo "package=$(ls -t main/packages | head -n1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload Artifact
|
||||
- name: Upload rootless artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.package_name.outputs.package }}
|
||||
path: ${{ github.workspace }}/main/packages/${{ steps.package_name.outputs.package }}
|
||||
name: com.faroukbmiled.ryukgram_${{ env.VERSION }}+debug-rootless.deb
|
||||
path: ${{ github.workspace }}/main/packages/com.faroukbmiled.ryukgram_${{ env.VERSION }}+debug-rootless.deb
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload rootful artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: com.faroukbmiled.ryukgram_${{ env.VERSION }}+debug-rootful.deb
|
||||
path: ${{ github.workspace }}/main/packages/com.faroukbmiled.ryukgram_${{ env.VERSION }}+debug-rootful.deb
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
delete_workflow_pattern:
|
||||
description: 'Name or filename of the workflow (if not set, all workflows are targeted)'
|
||||
required: false
|
||||
default: 'Build and Package SCInsta'
|
||||
default: 'Build and Package RyukGram'
|
||||
delete_workflow_by_state_pattern:
|
||||
description: 'Filter workflows by state: active, deleted, disabled_fork, disabled_inactivity, disabled_manually'
|
||||
required: true
|
||||
|
||||
@@ -65,34 +65,41 @@ jobs:
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build dylib
|
||||
run: |
|
||||
export THEOS="${{ github.workspace }}/theos"
|
||||
./build.sh dylib
|
||||
ls -la packages/
|
||||
- name: Setup FFmpegKit
|
||||
run: ./scripts/setup-ffmpegkit.sh
|
||||
|
||||
- name: Build rootless deb
|
||||
run: |
|
||||
export THEOS="${{ github.workspace }}/theos"
|
||||
./build.sh rootless
|
||||
cd packages
|
||||
mv "$(ls -t *.deb | head -n1)" "RyukGram_${VERSION}_rootless.deb"
|
||||
DEB="$(ls -t *-rootless.deb | head -n1)"
|
||||
[ -n "$DEB" ] && mv "$DEB" "RyukGram_${VERSION}_rootless.deb"
|
||||
ls -la
|
||||
|
||||
- name: Upload dylib artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: RyukGram_v${{ steps.version.outputs.version }}_dylib
|
||||
path: packages/RyukGram.dylib
|
||||
if-no-files-found: error
|
||||
- name: Build rootful deb
|
||||
run: |
|
||||
export THEOS="${{ github.workspace }}/theos"
|
||||
./build.sh rootful
|
||||
cd packages
|
||||
DEB="$(ls -t *-rootful.deb | head -n1)"
|
||||
[ -n "$DEB" ] && mv "$DEB" "RyukGram_${VERSION}_rootful.deb"
|
||||
ls -la
|
||||
|
||||
- name: Upload deb artifact
|
||||
- name: Upload rootless artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: RyukGram_v${{ steps.version.outputs.version }}_rootless
|
||||
path: packages/RyukGram_${{ env.VERSION }}_rootless.deb
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload rootful artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: RyukGram_v${{ steps.version.outputs.version }}_rootful
|
||||
path: packages/RyukGram_${{ env.VERSION }}_rootful.deb
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Check if release
|
||||
id: check_release
|
||||
run: |
|
||||
@@ -105,19 +112,19 @@ jobs:
|
||||
|
||||
- name: Generate release notes
|
||||
if: steps.check_release.outputs.should_release == 'true'
|
||||
id: notes
|
||||
run: |
|
||||
PENDING_FILE="PENDING_CHANGES.md"
|
||||
PREV_TAG=$(git tag --sort=-creatordate | grep -v "v${VERSION}$" | head -n1 || true)
|
||||
|
||||
{
|
||||
echo "## Changelog"
|
||||
echo ""
|
||||
if [ -f "$PENDING_FILE" ]; then
|
||||
# Drop the copy-as-commit header lines (HTML comment + [release] token)
|
||||
# so they don't appear in the published release body.
|
||||
sed -e '/^<!--/,/-->$/d' -e '/^\[release\]/d' "$PENDING_FILE"
|
||||
fi
|
||||
git log -1 --pretty=%B | tail -n +2 | sed -e '/^<!--/,/-->$/d' -e '/^\[release\]/d'
|
||||
echo ""
|
||||
echo "## Downloads"
|
||||
echo ""
|
||||
echo "| File | Description |"
|
||||
echo "|------|-------------|"
|
||||
echo "| \`RyukGram_rootless.deb\` | Rootless .deb (Dopamine/palera1n). Also works for sideloading via Feather/cyan. |"
|
||||
echo "| \`RyukGram_rootful.deb\` | Rootful .deb (unc0ver/checkra1n). |"
|
||||
echo ""
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
echo "**Full changelog:** [\`${PREV_TAG}\`...\`v${VERSION}\`](https://github.com/${{ github.repository }}/compare/${PREV_TAG}...v${VERSION})"
|
||||
@@ -128,16 +135,14 @@ jobs:
|
||||
|
||||
- name: Create Release
|
||||
if: steps.check_release.outputs.should_release == 'true'
|
||||
id: create_release
|
||||
run: |
|
||||
# Strip [release] from commit subject for the release title
|
||||
SUBJECT=$(git log -1 --pretty=%s | sed 's/\[release\]//g' | xargs)
|
||||
TITLE="${SUBJECT:-RyukGram v${VERSION}}"
|
||||
gh release create "v${VERSION}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "$TITLE" \
|
||||
--notes-file /tmp/release_notes.md \
|
||||
packages/RyukGram.dylib \
|
||||
"packages/RyukGram_${VERSION}_rootless.deb"
|
||||
"packages/RyukGram_${VERSION}_rootless.deb" \
|
||||
"packages/RyukGram_${VERSION}_rootful.deb"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -39,6 +39,19 @@ upstream-scinsta
|
||||
*.ipa
|
||||
*.dylib
|
||||
deploy.sh
|
||||
PENDING_CHANGES.md
|
||||
PENDING_CHANGES.md.bk
|
||||
PENDING_CHANGES.*
|
||||
wrapper/
|
||||
scripts/*.py
|
||||
scripts/__pycache__/
|
||||
|
||||
# FFmpegKit frameworks
|
||||
modules/ffmpegkit/
|
||||
|
||||
# External reference tweaks
|
||||
exp_flags/
|
||||
|
||||
# Source packaging
|
||||
zip-src.sh
|
||||
RyukGram-src-*.zip
|
||||
*.zip
|
||||
*_diff.txt
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Build SCInsta and deploy with IPA",
|
||||
"label": "Build RyukGram and deploy with IPA",
|
||||
"type": "shell",
|
||||
"command": "./build-dev.sh true",
|
||||
"group": {
|
||||
|
||||
@@ -7,9 +7,9 @@ include $(THEOS)/makefiles/common.mk
|
||||
TWEAK_NAME = RyukGram
|
||||
|
||||
$(TWEAK_NAME)_FILES = $(shell find src -type f \( -iname \*.x -o -iname \*.xm -o -iname \*.m \)) $(wildcard modules/JGProgressHUD/*.m) modules/fishhook/fishhook.c
|
||||
$(TWEAK_NAME)_FRAMEWORKS = UIKit Foundation CoreGraphics Photos CoreServices SystemConfiguration SafariServices Security QuartzCore AVFoundation UniformTypeIdentifiers
|
||||
$(TWEAK_NAME)_FRAMEWORKS = UIKit Foundation CoreGraphics Photos CoreServices SystemConfiguration SafariServices Security QuartzCore AVFoundation UniformTypeIdentifiers CoreLocation MapKit
|
||||
$(TWEAK_NAME)_PRIVATE_FRAMEWORKS = Preferences
|
||||
$(TWEAK_NAME)_CFLAGS = -fobjc-arc -Wno-unsupported-availability-guard -Wno-unused-value -Wno-deprecated-declarations -Wno-nullability-completeness -Wno-unused-function -Wno-incompatible-pointer-types
|
||||
$(TWEAK_NAME)_CFLAGS = -fobjc-arc -Wno-unsupported-availability-guard -Wno-unused-value -Wno-deprecated-declarations -Wno-nullability-completeness -Wno-unused-function -Wno-incompatible-pointer-types -include src/SCIPrefix.h
|
||||
$(TWEAK_NAME)_LOGOSFLAGS = --c warnings=none
|
||||
|
||||
CCFLAGS += -std=c++11
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# RyukGram
|
||||
A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com/SoCuul/SCInsta) with additional features and fixes.\
|
||||
`Version v1.1.4` | `Tested on Instagram 424.0.0`
|
||||
`Version v1.3.0` | `Tested on Instagram 430.0.0`
|
||||
|
||||
<a href="https://buymeacoffee.com/axryuk" target="_blank" rel="noopener noreferrer"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-violet.png" alt="Buy me a coffee" height="50"></a>
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> **Source pushes to this repository are paused for now, until further notice** — partly because a bit of the code has been finding its way into other paid projects, with no credit.
|
||||
> Releases and localization are still kept up to date here — new builds land on the [Releases](https://github.com/faroukbmiled/RyukGram/releases/latest) page, and translation pull requests are still welcome.
|
||||
|
||||
---
|
||||
|
||||
@@ -13,7 +21,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
>[!IMPORTANT]
|
||||
> Which type of device are you planning on installing this tweak on?
|
||||
> - Jailbroken/TrollStore device -> [Download pre-built tweak](https://github.com/faroukbmiled/RyukGram/releases/latest)
|
||||
> - Standard iOS device -> Sideload the dylib using Feather or similar
|
||||
> - Standard iOS device -> Sideload the .deb using Feather or similar
|
||||
|
||||
# Features
|
||||
> Features marked with **\*** are new or improved in RyukGram
|
||||
@@ -21,17 +29,25 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
### General
|
||||
- Hide ads
|
||||
- Hide Meta AI
|
||||
- Hide metrics (likes, comments, shares counts)
|
||||
- Hide the TestFlight beta update popup
|
||||
- Disable app haptics
|
||||
- Copy description
|
||||
- Copy comment text from long-press menu **\***
|
||||
- Download GIF comments **\***
|
||||
- Download / copy / expand GIF and image comments **\***
|
||||
- Custom GIF in comments — long-press the GIF button to paste any Giphy link **\***
|
||||
- Download audio from the reels audio page **\***
|
||||
- Profile copy button **\***
|
||||
- Replace domain in shared links — rewrite copied/shared links for embeds in Discord, Telegram, etc. with preset or custom domains **\***
|
||||
- Strip tracking params from shared links (igsh, utm) **\***
|
||||
- Replace domain in shared links for embeds (Discord, Telegram, etc.) **\***
|
||||
- Strip tracking params from shared links **\***
|
||||
- Open links in external browser **\***
|
||||
- Strip tracking from browser links **\***
|
||||
- Do not save recent searches
|
||||
- Open link from clipboard — long-press the search tab **\***
|
||||
- Use detailed (native) color picker
|
||||
- Enable liquid glass buttons
|
||||
- Enable liquid glass surfaces **\***
|
||||
- Liquid glass tab bar — Fixed (never shrink) / Hide on scroll
|
||||
- Enable teen app icons
|
||||
- IG Notes:
|
||||
- Hide notes tray
|
||||
@@ -43,83 +59,169 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
- No suggested chats
|
||||
- Hide trending searches
|
||||
- Hide explore posts grid
|
||||
- Skip sensitive content covers **\***
|
||||
- Live
|
||||
- Anonymous live viewing **\***
|
||||
- Toggle live comments **\***
|
||||
- Privacy
|
||||
- Hide RyukGram UI on screenshots, screen recordings, and mirroring **\***
|
||||
|
||||
### Feed
|
||||
- Hide stories tray
|
||||
- Hide suggested stories **\***
|
||||
- View profile picture from story tray long-press menu **\***
|
||||
- Hide entire feed
|
||||
- No suggested posts
|
||||
- No suggested for you (accounts)
|
||||
- No suggested reels
|
||||
- No suggested threads posts
|
||||
- Disable video autoplay
|
||||
- Media zoom — long press media to expand in full-screen viewer **\***
|
||||
- Start media muted — expanded videos open with sound off **\***
|
||||
- Custom date format — feed, notes/comments/stories, and DMs; optional relative threshold, compact "1h" style, and a Combine with date picker (`Jan 5, 2026 (2h)` or `2h – Jan 5, 2026`) **\***
|
||||
- Disable background refresh, home button refresh, and home button scroll **\***
|
||||
- Disable reels tab button refresh **\***
|
||||
- Hide repost button in feed **\***
|
||||
|
||||
### Reels
|
||||
- Modify tap controls
|
||||
- Auto-scroll reels mode **\***
|
||||
- Always show progress scrubber
|
||||
- Disable auto-unmuting reels (properly blocks mute switch, volume buttons, and announcer broadcasts) **\***
|
||||
- Disable auto-unmuting reels **\***
|
||||
- Confirm reel refresh
|
||||
- Unlock password-locked reels **\***
|
||||
- Hide reels header
|
||||
- Hide repost button in reels **\***
|
||||
- Hide friends avatars on the reels Friends tab **\***
|
||||
- Hide social context overlay on reels (reposted/commented bubbles) **\***
|
||||
- Hide "Made with Edits" / "Open in Edits" promo pills on reels **\***
|
||||
- Hide reels blend button
|
||||
- Swipe a reel left to open the author's profile
|
||||
- Disable scrolling reels
|
||||
- Prevent doom scrolling (limit maximum viewable reels)
|
||||
- Enhanced Pause/Play mode (when Pause/Play tap control is set): **\***
|
||||
- Mute toggle auto-hidden, only play/pause icon visible
|
||||
- Mute toggle auto-hidden
|
||||
- Audio forced on in reels tab
|
||||
- Play indicator properly hidden when video plays (fixes IG bug after hold/zoom)
|
||||
- Play indicator hidden during playback
|
||||
- Playback toggle synced with overlay during hold/zoom
|
||||
- Works across IG A/B test variants
|
||||
- Optional tap-to-mute on photo reels
|
||||
|
||||
### Action buttons **\***
|
||||
- Context-aware action menu on feed, reels, and stories **\***
|
||||
- Configurable default tap action per context **\***
|
||||
- Global icon picker — change the icon used across feed, stories, reels and DMs **\***
|
||||
- Optional date header at the top of the action menu **\***
|
||||
- Carousel and multi-story reel support with bulk download **\***
|
||||
- Repost via IG's native creation flow **\***
|
||||
- Full-screen media viewer with zoom and swipe **\***
|
||||
- Story playback pauses when menus are open **\***
|
||||
|
||||
### Profile **\***
|
||||
- Zoom profile photo — long press to view full-screen **\***
|
||||
- Save profile picture
|
||||
- View highlight cover from profile long-press menu **\***
|
||||
- Profile copy button **\***
|
||||
- Follow indicator — shows whether the user follows you (off / on / colored) **\***
|
||||
- Copy note on long press **\***
|
||||
- Fake profile stats — verified badge and follower/following/post counts **\***
|
||||
- Show full follower / post count on profile headers **\***
|
||||
- Profile card details — view count, like count and upload date overlay on each post / reel card, with optional short-number format and on-demand fetch for missing counts **\***
|
||||
- Filter & sort follower/following lists — reorder by mutuals, who follows you, verified or A–Z, filter to just those, plus load-more and jump to top/bottom **\***
|
||||
|
||||
### Profile Analyzer (beta) **\***
|
||||
- Follower and following scans with progress and cancel **\***
|
||||
- Mutuals and non-followbacks lists **\***
|
||||
- New and lost followers/following trackers across scans **\***
|
||||
- Profile change history — username, name, bio, pfp **\***
|
||||
- Searchable lists with inline and batch follow/unfollow **\***
|
||||
- Visited profiles tracker — log every profile you open with date / verified / private filters **\***
|
||||
- Pull-to-refresh on the visited list re-syncs identity and pictures from IG **\***
|
||||
|
||||
### Saving
|
||||
- Download feed posts (photo + video)
|
||||
- Download reels
|
||||
- Download stories
|
||||
- Save profile picture
|
||||
- Download buttons on media — tap a button directly on feed posts, reels sidebar, and story overlay **\***
|
||||
- Download method — choose between download button or long-press gesture **\***
|
||||
- Download highlight cover from profile long-press menu **\***
|
||||
- Save action — choose between share sheet or save directly to Photos **\***
|
||||
- Save to RyukGram album — optional toggle that routes downloads (and share-sheet "Save to Photos" picks) into a dedicated "RyukGram" album in Photos **\***
|
||||
- Download confirmation — optional confirmation dialog before downloading **\***
|
||||
- Non-blocking download HUD — pill-style progress at the top, tap to cancel **\***
|
||||
- Debug fallback — if IG updates break downloads, shows diagnostic info instead of crashing **\***
|
||||
- *Customize finger count for long-press*
|
||||
- *Customize hold time for long-press*
|
||||
- Enhanced HD downloads up to 1080p **\***
|
||||
- Quality picker with preview playback **\***
|
||||
- Audio-only and raw photo download options **\***
|
||||
- Fallback to 720p without FFmpegKit **\***
|
||||
- Live progress through both download and encode **\***
|
||||
- Download manager — parallel downloads with a configurable limit, one combined progress pill, and a live queue with cancel / retry / redownload, long-press share / save, and bulk select; opens from the pill, a home shortcut, or settings **\***
|
||||
- Auto-retry dropped downloads — parks offline downloads and resumes them when the connection returns **\***
|
||||
- Save to RyukGram album — every download + share-sheet "Save to Photos" routes into a dedicated album with a customizable name **\***
|
||||
- Enhanced media resolution — IG's CDN ships higher-quality images **\***
|
||||
- Advanced encoding panel — codec (HW / libx264), preset, tune, H.264 profile + level, CRF / bitrate, pixel format, max resolution, frame rate, audio codec / bitrate / channels / sample rate, faststart, strip metadata **\***
|
||||
- Download confirmation dialog **\***
|
||||
- Output filenames formatted as `@username_context_timestamp` across every save surface (feed / reels / DMs / notes / comments / disappearing media) **\***
|
||||
- Legacy long-press gesture (deprecated, customizable finger count + hold time) **\***
|
||||
|
||||
### Gallery **\***
|
||||
- On-device gallery — every download can mirror into an in-app library **\***
|
||||
- Stores images, videos, audio (m4a/aac/mp3/ogg/opus/...) and animated GIFs **\***
|
||||
- Filter chips by type, source (feed / reels / stories / DMs / profile / notes / comments) and uploader; favorites; folders **\***
|
||||
- Group by user — sections or virtual folders alongside your real folders **\***
|
||||
- Folder cells show a thumbnail collage + item count and last-activity date **\***
|
||||
- In-app preview carousel — image / video / audio scrubber / GIF playback **\***
|
||||
- Audio + GIF picker — DM Upload Audio and (in future surfaces) Comment GIF can pull straight from the gallery **\***
|
||||
- "Download to Gallery" submenu on every download surface when the gallery is enabled, with Photos / Gallery / Share options for audio **\***
|
||||
|
||||
### Stories and messages
|
||||
- Keep deleted messages (preserves unsent messages with visual indicator and notification pill) **\***
|
||||
- Keep deleted messages **\***
|
||||
- Deleted messages log — dedicated UI for every unsent message type, per-sender groups, search, filter, bulk Save / Share / Delete, edit history per message **\***
|
||||
- Hide trailing action buttons on preserved messages
|
||||
- Warn before clearing on refresh — optional confirmation when pulling to refresh the DMs tab if preserved messages would be cleared **\***
|
||||
- Warn before pull-to-refresh clears preserved messages **\***
|
||||
- Manually mark messages as seen (button or toggle mode) **\***
|
||||
- Long-press the seen button for quick actions **\***
|
||||
- Auto mark seen on send (marks messages as read when you send any message) **\***
|
||||
- Auto mark seen on typing (marks messages as read the moment you start typing, even when typing status is hidden) **\***
|
||||
- Auto mark seen on send **\***
|
||||
- Auto mark seen on typing **\***
|
||||
- Mark seen on story like **\***
|
||||
- Advance to next story when marking as seen — tapping the eye button auto-skips to the next story **\***
|
||||
- Advance on story like — liking a story auto-skips to the next one **\***
|
||||
- Per-chat read-receipt list with blocking mode — "Block all" (exclude list) or "Block selected only" (include list). Long-press any DM chat to add/remove. Settings page with search, sort, multi-select, and per-entry keep-deleted override **\***
|
||||
- Send audio as file — send audio files as voice messages from the DM plus menu **\***
|
||||
- Download voice messages — adds a Download option to the long-press menu on voice messages, saves as M4A via share sheet **\***
|
||||
- Mark seen on story reply **\***
|
||||
- Advance to next story when marking as seen **\***
|
||||
- Advance on story like **\***
|
||||
- Advance on story reply **\***
|
||||
- Per-chat read-receipt exclusion list with Block all / Block selected mode **\***
|
||||
- Send audio as file — DM plus menu, or long-press the camera button while replying **\***
|
||||
- Download voice messages **\***
|
||||
- Disable typing status
|
||||
- Unlimited replay of direct stories
|
||||
- Disable vanish mode swipe **\***
|
||||
- Hide voice/video call buttons (independent toggles) **\***
|
||||
- Hide send to group chat in the share sheet **\***
|
||||
- Bypass DM character limit **\***
|
||||
- Pin recipients on long-press in the share sheet — pinned chats render at the top **\***
|
||||
- Custom chat backgrounds — per-chat images injected into IG's native theme picker, library upload with built-in cropper, per-image opacity / blur / dim, optional global default, per-account list of chats with backgrounds **\***
|
||||
- Unlimited replay of direct stories **\***
|
||||
- Full last active date **\***
|
||||
- Send files in DMs (experimental) **\***
|
||||
- Hold the DM tab button to open the on-device gallery **\***
|
||||
- Notes actions — copy text, download GIF / audio (Photos or Gallery) **\***
|
||||
- Copy note text on long press **\***
|
||||
- Disable view-once limitations
|
||||
- Disable screenshot detection
|
||||
- Disable story seen receipt (blocks network upload, toggleable at runtime without restart) **\***
|
||||
- Keep stories visually unseen — keeps the colorful ring in the tray after viewing **\***
|
||||
- Manual mark story as seen — button on story overlay to selectively mark stories as seen (button or toggle mode) **\***
|
||||
- Disable story seen receipt **\***
|
||||
- Keep stories visually seen locally **\***
|
||||
- Manual mark story as seen (button or toggle mode) **\***
|
||||
- Long-press the story seen button for quick actions **\***
|
||||
- Per-user story seen-receipt list with blocking mode — "Block all" (exclude list) or "Block selected only" (include list). Manage via 3-dot menu, eye button long-press, or settings list **\***
|
||||
- Story audio mute/unmute toggle — button on the story overlay and 3-dot menu to toggle audio **\***
|
||||
- Stop story auto-advance — stories won't auto-skip when the timer ends **\***
|
||||
- Story download button — download directly from the story overlay **\***
|
||||
- Download disappearing DM media (photos + videos) **\***
|
||||
- Mark disappearing messages as viewed button **\***
|
||||
- Upload audio as voice message — send audio files, extract audio from videos, with built-in trim editor **\***
|
||||
- Per-user story seen-receipt exclusion list with Block all / Block selected mode **\***
|
||||
- Story audio mute/unmute toggle **\***
|
||||
- View story mentions, with optional quick-access overlay button and count badge **\***
|
||||
- Hide stories midcards (Trending / Music) **\***
|
||||
- Stop story auto-advance **\***
|
||||
- Reveal poll/slider vote counts and quiz answers on stories and reels before interacting **\***
|
||||
- Force legacy Quiz and Reveal stickers back into the story composer tray **\***
|
||||
- Bypass Reveal sticker — view stories blurred behind a Reveal sticker without DMing the author **\***
|
||||
- Allow video in photo sticker — story photo sticker picker accepts videos too **\***
|
||||
- Custom solid or gradient color for music and lyric stickers **\***
|
||||
- Disappearing DM media overlay — action button, mark-as-viewed eye, and audio toggle **\***
|
||||
- Download disappearing DM media **\***
|
||||
- Upload audio as voice message with built-in trim editor **\***
|
||||
- Disable instants creation
|
||||
- Send Instants from your photo album — gallery button on the Instants camera with a built-in square cropper, posts through IG's native capture flow; pick from the in-app gallery or Photos library when the gallery is enabled
|
||||
- Allow screenshots on Instants — bypasses the screenshot/screen-record block, scoped to the Instants viewer only
|
||||
- Instants action button — Expand / Save (Photos / Gallery) / Share / Save all, fully configurable through the standard action-menu config
|
||||
- Confirm Instants emoji reaction — optional confirmation before a quick-reaction sends
|
||||
- Confirm Instants capture + Confirm switching Instant
|
||||
- Save to Gallery from the expanded media viewer — share button surfaces a Save / Share menu when the gallery is enabled, with username / source attribution carried through
|
||||
|
||||
### Navigation
|
||||
### Interface **\***
|
||||
- Tab bar shortcuts — Home shortcut button + Action button icon picker, grouped at the top of the page
|
||||
- Notifications — universal in-app pill (Minimal / Colorful / Glow / Island), per-action routing (custom pill / IG-native / off), top or bottom position, master kill switch, swipe-to-dismiss, multi-pill stacking
|
||||
- Modify tab bar icon order
|
||||
- Modify swiping between tabs
|
||||
- Hiding tabs
|
||||
@@ -127,42 +229,107 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
- Hide explore tab
|
||||
- Hide reels tab
|
||||
- Hide create tab
|
||||
- Hide messages tab
|
||||
- Messages-only mode — inbox + profile only, launch straight into inbox **\***
|
||||
- Hide tab bar sub-toggle — floating settings gear replaces it **\***
|
||||
- Launch tab — pick which tab the app opens to **\***
|
||||
- Home shortcut button — extra button on the home top bar with a configurable multi-action menu (Gallery / Settings / Security & Privacy / Hidden chats / Locked chats / Profile Analyzer / Deleted messages / Fake location / Clear cache / Changelog) **\***
|
||||
- Experimental flags
|
||||
|
||||
### Confirm actions
|
||||
- Confirm like: Posts/Stories
|
||||
- Confirm story emoji reaction **\***
|
||||
- Confirm note like + emoji reaction **\***
|
||||
- Confirm like: Reels
|
||||
- Confirm follow
|
||||
- Confirm unfollow **\***
|
||||
- Confirm repost
|
||||
- Confirm call
|
||||
- Confirm voice call **\***
|
||||
- Confirm video call **\***
|
||||
- Confirm voice messages
|
||||
- Confirm follow requests
|
||||
- Confirm shh mode (disappearing messages)
|
||||
- Confirm vanish mode
|
||||
- Confirm posting comment
|
||||
- Confirm send to group chat **\***
|
||||
- Confirm changing direct message theme
|
||||
- Confirm sticker interaction
|
||||
- Confirm sticker interaction (stories / highlights, per-surface: disabled / all / reaction stickers only) **\***
|
||||
|
||||
### Fake location **\***
|
||||
- Override location app-wide for any IG feature reading coordinates
|
||||
- MapKit picker with search + reverse-geocoded names
|
||||
- Saved presets
|
||||
- Quick toggle button on the Friends Map
|
||||
|
||||
### Theme **\***
|
||||
- Theme picker — Off / Light / Dark / OLED, with optional Force theme to override the iOS appearance
|
||||
- OLED chat theme — pure black DM thread and incoming bubbles
|
||||
- Keyboard theme — dark or OLED, sticks through search keyboard activations
|
||||
- Apply & restart, plus Reset theme to revert every theme option
|
||||
|
||||
### Security & Privacy **\***
|
||||
- Passcode + biometric lock — gates tweak settings, gallery, deleted-messages log, Profile Analyzer, DM inbox, individual chats, and Instagram itself
|
||||
- Per-target idle timeout, re-lock on background, lock every time, independent session
|
||||
- Hidden chats — long-press a DM to hide it; managed under S&P
|
||||
- Per-account lists (excluded chats, hidden chats, locked chats, share-sheet pins) — switching IG accounts shows that account's own data
|
||||
- App-switcher snapshot shroud — covers IG content when a locked surface is visible
|
||||
- Hide message preview for locked chats in the inbox
|
||||
|
||||
### Tweak settings **\***
|
||||
- Search bar in the main settings page — recursively finds any setting across nested pages with a breadcrumb to its location
|
||||
- Pause playback when opening settings (toggleable) **\***
|
||||
- Search bar with breadcrumbs across nested pages
|
||||
- What's new indicator — a dot marks newly added settings and clears once viewed **\***
|
||||
- Pause playback when opening settings **\***
|
||||
- Quick-access via long-press on feed tab **\***
|
||||
- Native Instagram icons throughout settings and in-app menus **\***
|
||||
|
||||
### Advanced experimental features **\***
|
||||
- Toggle hidden Instagram experiments: QuickSnap (Instants), Direct Notes reply types, Friend Map, Homecoming, Prism
|
||||
- Batched changes with an Apply & restart button
|
||||
- Auto-reset after 3 consecutive launch crashes
|
||||
|
||||
### Backup & Restore **\***
|
||||
- Export RyukGram settings as a JSON file
|
||||
- Import settings from a JSON file
|
||||
- Searchable, collapsible, editable preview before saving or applying
|
||||
- Export RyukGram settings as JSON
|
||||
- Import settings from JSON
|
||||
- Preview before saving or applying
|
||||
|
||||
### Localization **\***
|
||||
- Multi-language UI with fallback to English **\***
|
||||
- Built-in language picker in Settings **\***
|
||||
- Currently shipping: **English**, **Spanish**, **Russian**, **Korean**, **Arabic**, **Chinese (Traditional)**, **Chinese (Simplified)**, **Portuguese (Brazil)**, **Turkish**
|
||||
|
||||
### Optimization
|
||||
- Automatically clears unneeded cache folders, reducing the size of your Instagram installation
|
||||
- Clear Instagram cache on demand with optional auto-clear interval, with a toggle to preserve DMs, drafts, and Notes **\***
|
||||
|
||||
# Translating RyukGram
|
||||
Want to see RyukGram in your language? Two ways:
|
||||
|
||||
### Option A: In-app (fastest)
|
||||
1. Open **Settings → Debug → Localization → Export strings** — pick a language (English for a fresh start, or an existing one to fix) and share its `.strings` file to yourself.
|
||||
2. Translate the **right-hand side** of every `"key" = "value";` line. Never touch the left-hand side.
|
||||
3. Go to **Debug → Localization → Update → + Add new language** — enter your language code (e.g. `fr`), pick the translated file, restart.
|
||||
4. Your language now appears in the globe menu. Test it, tweak it, re-import as needed.
|
||||
5. When ready, open a pull request with the file at `src/Localization/Resources/<code>.lproj/Localizable.strings`.
|
||||
|
||||
### Option B: PR directly
|
||||
1. Copy `src/Localization/Resources/en.lproj/Localizable.strings` into a new folder: `<code>.lproj/Localizable.strings`
|
||||
2. Translate the right-hand side of every line.
|
||||
3. Keep format specifiers (`%@`, `%lu`, `%d`, `%1$@`…) exactly as-is. Use positional specifiers if your language needs different word order.
|
||||
4. Keep section banners and structure — makes the diff easy to review.
|
||||
5. Open a PR at <https://github.com/faroukbmiled/RyukGram/pulls>. Title it e.g. `l10n: Add French translation`.
|
||||
|
||||
Partial translations are welcome — untranslated keys fall back to English at runtime.
|
||||
|
||||
If you find a string that still renders in English on a translated build, open an issue with a screenshot.
|
||||
|
||||
## Known Issues
|
||||
- Preserved unsent messages cannot be removed using "Delete for you". Pull to refresh in the DMs tab clears all preserved messages (with optional confirmation if "Warn before clearing on refresh" is enabled).
|
||||
- Preserved unsent messages cannot be removed using "Delete for you". Pull to refresh in the DMs tab clears the active account's preserved messages (with optional confirmation if "Warn before clearing on refresh" is enabled).
|
||||
- "Delete for you" detection uses a ~2 second window after the local action. If a real other-party unsend happens to land in the same window, it may not be preserved. Rare in practice and limited to that specific overlap.
|
||||
- With Liquid Glass buttons + Hide UI on capture both on, the DM eye leaves an empty glass bubble in captures — IG draws that backdrop, not the tweak, so it's outside our redaction.
|
||||
|
||||
# Opening Tweak Settings
|
||||
|
||||
| | |
|
||||
|:-------------------------------------------:|:-------------------------------------------:|
|
||||
| <img src="https://i.imgur.com/uPMcugZ.png"> | <img src="https://i.imgur.com/ctIiL7i.png"> |
|
||||
| <img src="https://i.imgur.com/uPMcugZ.png"> | <img src="https://i.imgur.com/RUlsg4k.jpeg"> |
|
||||
|
||||
# Building from source
|
||||
### Prerequisites
|
||||
@@ -184,10 +351,30 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
### Run build script
|
||||
```sh
|
||||
$ chmod +x build.sh
|
||||
$ ./build.sh <sideload/rootless/rootful>
|
||||
$ ./build.sh <sideload/sidestore/rootless/rootful>
|
||||
```
|
||||
|
||||
# Credits
|
||||
- [SCInsta](https://github.com/SoCuul/SCInsta) by [@SoCuul](https://github.com/SoCuul) — original tweak this fork is based on
|
||||
- [@BandarHL](https://github.com/BandarHL) — creator of the original BHInstagram project
|
||||
- [@faroukbmiled](https://github.com/faroukbmiled) — RyukGram modifications and additional features
|
||||
- [Instaoled](https://t.me/ciesIPAs) by @VAXMG — OLED theme inspiration
|
||||
- [@euoradan](https://t.me/euoradan) (Radan) — experimental Instagram feature flag research
|
||||
- [@Mikasa-san](https://github.com/Mikasa-san) — code contributions
|
||||
- [@n3d1117](https://github.com/n3d1117) (Edoardo) — Following feed mode (ported from [InstaSane](https://github.com/n3d1117/InstaSane))
|
||||
- [@erupts0](https://github.com/erupts0) (John) — testing and feature suggestions
|
||||
- [BillyCurtis/OpenInstagramSafariExtension](https://github.com/BillyCurtis/OpenInstagramSafariExtension) — base for the bundled Safari extension
|
||||
- [@asdfzxcvbn](https://github.com/asdfzxcvbn) — [ipapatch](https://github.com/asdfzxcvbn/ipapatch) and [zxPluginsInject](https://github.com/asdfzxcvbn/zxPluginsInject)
|
||||
- Furamako — Spanish translation
|
||||
- [@ch1tmdgus](https://github.com/ch1tmdgus) (N4C) — Korean translation
|
||||
- [ZomkaDEV](https://github.com/ZomkaDEV) — Russian translation
|
||||
- [@bruuhim](https://github.com/bruuhim) — Arabic translation
|
||||
- [@jaydenjcpy](https://github.com/jaydenjcpy) — Chinese (Traditional and Simplified) translation
|
||||
- Bruno ([@brunorainha](https://github.com/brunorainha)) — Portuguese (Brazil) translation
|
||||
- [@yesnt10](https://github.com/yesnt10) — Turkish translation
|
||||
|
||||
# Support
|
||||
|
||||
RyukGram is free and open source. If you'd like to support development:
|
||||
|
||||
- [☕ Donate to Ryuk (RyukGram)](https://buymeacoffee.com/axryuk)
|
||||
- [☕ Donate to SoCuul (original SCInsta)](https://ko-fi.com/SoCuul) — RyukGram wouldn't exist without SoCuul's original SCInsta, so showing them some love is always welcome
|
||||
|
||||
@@ -22,7 +22,7 @@ else
|
||||
make DEV=1
|
||||
|
||||
# Change framework locations to @rpath
|
||||
install_name_tool -change "/Library/Frameworks/CydiaSubstrate.framework/CydiaSubstrate" "@rpath/CydiaSubstrate.framework/CydiaSubstrate" ".theos/obj/debug/SCInsta.dylib" 2>/dev/null || true
|
||||
install_name_tool -change "/Library/Frameworks/CydiaSubstrate.framework/CydiaSubstrate" "@rpath/CydiaSubstrate.framework/CydiaSubstrate" ".theos/obj/debug/RyukGram.dylib" 2>/dev/null || true
|
||||
|
||||
_scinsta_devquick_after
|
||||
fi
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Auto-detect THEOS if not set
|
||||
# Auto-detect THEOS if not set
|
||||
if [ -z "$THEOS" ]; then
|
||||
if [ -d "$HOME/theos" ]; then
|
||||
export THEOS="$HOME/theos"
|
||||
@@ -15,12 +15,142 @@ fi
|
||||
CMAKE_OSX_ARCHITECTURES="arm64e;arm64"
|
||||
CMAKE_OSX_SYSROOT="iphoneos"
|
||||
|
||||
# Copy Localization resources (*.lproj) into a RyukGram.bundle.
|
||||
# Arg 1: destination bundle directory (created if missing).
|
||||
copy_localization_into_bundle() {
|
||||
local DEST="$1"
|
||||
local SRC="src/Localization/Resources"
|
||||
[ -d "$SRC" ] || return 0
|
||||
mkdir -p "$DEST"
|
||||
for lproj in "$SRC"/*.lproj; do
|
||||
[ -d "$lproj" ] || continue
|
||||
cp -R "$lproj" "$DEST/"
|
||||
done
|
||||
}
|
||||
|
||||
# Copy generic static assets (PNGs, etc.) into a RyukGram.bundle. Used for
|
||||
# bundled images the tweak loads via SCILocalizationBundle().
|
||||
# Arg 1: destination bundle directory (created if missing).
|
||||
copy_bundle_assets() {
|
||||
local DEST="$1"
|
||||
local SRC="src/BundleAssets"
|
||||
[ -d "$SRC" ] || return 0
|
||||
mkdir -p "$DEST"
|
||||
find "$SRC" -maxdepth 1 -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.pdf' \) \
|
||||
-exec cp {} "$DEST/" \;
|
||||
}
|
||||
|
||||
# Collect all FFmpegKit frameworks for injection
|
||||
ffmpegkit_frameworks() {
|
||||
local fws=""
|
||||
if [ -d "modules/ffmpegkit/ffmpegkit.framework" ]; then
|
||||
for fw in modules/ffmpegkit/*.framework; do
|
||||
fws="$fws $fw"
|
||||
done
|
||||
fi
|
||||
echo "$fws"
|
||||
}
|
||||
|
||||
# Inject RyukGram.bundle into a .deb:
|
||||
# - Always: localization lproj resources.
|
||||
# - Optional: FFmpegKit frameworks (renamed *_sci to avoid collisions).
|
||||
# Path: Library/Application Support/RyukGram.bundle/ — jailbreak dlopens by full
|
||||
# path, Feather copies .bundle without injecting load commands for sideload.
|
||||
# Arg 1: path to .deb (cwd must be packages/)
|
||||
inject_bundle_into_deb() {
|
||||
local BASE_DEB="$1"
|
||||
local TMPDIR=$(mktemp -d)
|
||||
dpkg-deb -R "$BASE_DEB" "$TMPDIR"
|
||||
local DYLIB_DIR=$(find "$TMPDIR" -name "RyukGram.dylib" -exec dirname {} \; | head -1)
|
||||
[ -n "$DYLIB_DIR" ] || { rm -rf "$TMPDIR"; return; }
|
||||
|
||||
local PREFIX=""
|
||||
[[ "$DYLIB_DIR" == *"/var/jb/"* ]] && PREFIX="var/jb/"
|
||||
|
||||
local BUNDLE_DIR="$TMPDIR/${PREFIX}Library/Application Support/RyukGram.bundle"
|
||||
mkdir -p "$BUNDLE_DIR"
|
||||
( cd .. && copy_localization_into_bundle "$BUNDLE_DIR" && copy_bundle_assets "$BUNDLE_DIR" )
|
||||
|
||||
if [ -d "../modules/ffmpegkit/ffmpegkit.framework" ]; then
|
||||
for fw in ../modules/ffmpegkit/*.framework; do
|
||||
cp -R "$fw" "$BUNDLE_DIR/"
|
||||
done
|
||||
|
||||
local LIBS="libavutil libavcodec libavformat libavfilter libavdevice libswresample libswscale"
|
||||
for lib in $LIBS; do
|
||||
mv "$BUNDLE_DIR/${lib}.framework" "$BUNDLE_DIR/${lib}_sci.framework"
|
||||
install_name_tool -id "@rpath/${lib}_sci.framework/${lib}" \
|
||||
"$BUNDLE_DIR/${lib}_sci.framework/${lib}"
|
||||
done
|
||||
for target in "$BUNDLE_DIR/ffmpegkit.framework/ffmpegkit" \
|
||||
"$BUNDLE_DIR"/libav*_sci.framework/libav* \
|
||||
"$BUNDLE_DIR"/libsw*_sci.framework/libsw*; do
|
||||
[ -f "$target" ] || continue
|
||||
for lib in $LIBS; do
|
||||
install_name_tool -change \
|
||||
"@rpath/${lib}.framework/${lib}" \
|
||||
"@rpath/${lib}_sci.framework/${lib}" \
|
||||
"$target" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
install_name_tool -add_rpath @loader_path/.. \
|
||||
"$BUNDLE_DIR/ffmpegkit.framework/ffmpegkit" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
dpkg-deb -b "$TMPDIR" "$BASE_DEB"
|
||||
rm -rf "$TMPDIR"
|
||||
}
|
||||
|
||||
# Build zxPluginsInject.dylib -> packages/zxPluginsInject.dylib
|
||||
build_zxpi_dylib() {
|
||||
local MOD_DIR="modules/zxPluginsInject"
|
||||
local DYLIB_OUT="$MOD_DIR/.theos/obj/zxPluginsInject.dylib"
|
||||
|
||||
if [ -z "${THEOS:-}" ]; then
|
||||
if [ -d "$HOME/theos" ]; then
|
||||
export THEOS="$HOME/theos"
|
||||
else
|
||||
echo -e '\033[1m\033[0;31mTHEOS not set and ~/theos not found\033[0m' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
( cd "$MOD_DIR" && make FINALPACKAGE=1 >/dev/null )
|
||||
|
||||
[ -f "$DYLIB_OUT" ] || {
|
||||
echo -e '\033[1m\033[0;31mzxPluginsInject.dylib build failed\033[0m' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p packages
|
||||
cp "$DYLIB_OUT" packages/zxPluginsInject.dylib
|
||||
# Match the @rpath LC that ipapatch writes into target binaries.
|
||||
install_name_tool -id "@rpath/zxPluginsInject.dylib" \
|
||||
packages/zxPluginsInject.dylib 2>/dev/null || true
|
||||
}
|
||||
|
||||
# LC-inject zxPluginsInject.dylib into main exec + every .appex in the IPA.
|
||||
# Arg 1: path to the IPA
|
||||
run_ipapatch() {
|
||||
local IPA="$1"
|
||||
if ! command -v ipapatch &> /dev/null; then
|
||||
echo -e '\033[1m\033[0;31mipapatch not found. Install it from:\033[0m'
|
||||
echo ' https://github.com/asdfzxcvbn/ipapatch/releases/latest'
|
||||
exit 1
|
||||
fi
|
||||
echo -e '\033[1m\033[32mRunning ipapatch (zxPluginsInject LC injection)\033[0m'
|
||||
ipapatch --input "$IPA" --inplace --noconfirm --dylib packages/zxPluginsInject.dylib
|
||||
}
|
||||
|
||||
# Build just the dylib (for Feather/manual injection)
|
||||
if [ "$1" == "dylib" ];
|
||||
then
|
||||
|
||||
make clean 2>/dev/null || true
|
||||
rm -rf .theos
|
||||
# --fast: incremental build (no clean)
|
||||
if [ "$2" != "--fast" ]; then
|
||||
make clean 2>/dev/null || true
|
||||
rm -rf .theos
|
||||
fi
|
||||
|
||||
echo -e '\033[1m\033[32mBuilding RyukGram dylib\033[0m'
|
||||
|
||||
@@ -29,7 +159,11 @@ then
|
||||
mkdir -p packages
|
||||
cp .theos/obj/debug/RyukGram.dylib packages/RyukGram.dylib
|
||||
|
||||
echo -e "\033[1m\033[32mDone!\033[0m\n\nDylib at: $(pwd)/packages/RyukGram.dylib"
|
||||
# Ship localization bundle next to the dylib so Feather/manual installs work.
|
||||
copy_localization_into_bundle "packages/RyukGram.bundle"
|
||||
copy_bundle_assets "packages/RyukGram.bundle"
|
||||
|
||||
echo -e "\033[1m\033[32mDone!\033[0m\n\nDylib at: $(pwd)/packages/RyukGram.dylib\nBundle at: $(pwd)/packages/RyukGram.bundle"
|
||||
|
||||
# Build sideloaded IPA
|
||||
elif [ "$1" == "sideload" ];
|
||||
@@ -88,9 +222,19 @@ then
|
||||
rm -rf .theos
|
||||
|
||||
# Check for decrypted Instagram IPA
|
||||
ipaFile="$(find ./packages/ -name '*com.burbn.instagram*.ipa' -type f -exec basename {} \; 2>/dev/null || true)"
|
||||
mkdir -p packages
|
||||
ipaFile="$(find ./packages/ -maxdepth 1 -type f \( -iname '*com.burbn.instagram*.ipa' -o -iname 'Instagram*.ipa' -o -iname '[0-9]*.ipa' \) ! -iname 'RyukGram*.ipa' -exec basename {} \; 2>/dev/null | head -1)"
|
||||
if [ -z "${ipaFile}" ]; then
|
||||
echo -e '\033[1m\033[0;31m./packages/com.burbn.instagram.ipa not found.\nPlease put a decrypted Instagram IPA in its path.\033[0m'
|
||||
# Auto-move any Instagram IPA from cwd into packages/
|
||||
cwdIpa="$(find . -maxdepth 1 -type f \( -iname '*com.burbn.instagram*.ipa' -o -iname 'Instagram*.ipa' -o -iname '[0-9]*.ipa' \) 2>/dev/null | head -1)"
|
||||
if [ -n "$cwdIpa" ]; then
|
||||
echo -e "\033[1m\033[32mMoving $(basename "$cwdIpa") → packages/\033[0m"
|
||||
mv "$cwdIpa" packages/
|
||||
ipaFile="$(basename "$cwdIpa")"
|
||||
fi
|
||||
fi
|
||||
if [ -z "${ipaFile}" ]; then
|
||||
echo -e '\033[1m\033[0;31mDecrypted Instagram IPA not found.\nPlace a *com.burbn.instagram*.ipa in ./ or ./packages/.\033[0m'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -107,9 +251,6 @@ then
|
||||
if ! command -v ipapatch &> /dev/null; then
|
||||
echo -e '\033[1m\033[0;31mipapatch not found. Install it from:\033[0m'
|
||||
echo ' https://github.com/asdfzxcvbn/ipapatch/releases/latest'
|
||||
echo
|
||||
echo -e '\033[0;33mUse ./build.sh sideload --buildonly to just compile without creating the IPA.\033[0m'
|
||||
echo -e '\033[0;33mOr use ./build.sh dylib to build the dylib for Feather injection.\033[0m'
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -118,6 +259,10 @@ then
|
||||
|
||||
make $MAKEARGS
|
||||
|
||||
# Build zxPluginsInject.dylib so ipapatch can inject it after cyan
|
||||
echo -e '\033[1m\033[32mBuilding zxPluginsInject.dylib\033[0m'
|
||||
build_zxpi_dylib
|
||||
|
||||
# Copy dylib to packages
|
||||
mkdir -p packages
|
||||
cp .theos/obj/debug/RyukGram.dylib packages/RyukGram.dylib
|
||||
@@ -128,24 +273,72 @@ then
|
||||
exit
|
||||
fi
|
||||
|
||||
TWEAKPATH=".theos/obj/debug/RyukGram.dylib"
|
||||
if [ "$2" == "--devquick" ];
|
||||
then
|
||||
# Exclude RyukGram.dylib from IPA for livecontainer quick builds
|
||||
TWEAKPATH=""
|
||||
# Build RyukGram.bundle with renamed frameworks for cyan injection
|
||||
BUNDLE_PATH="packages/RyukGram.bundle"
|
||||
rm -rf "$BUNDLE_PATH"
|
||||
mkdir -p "$BUNDLE_PATH"
|
||||
copy_localization_into_bundle "$BUNDLE_PATH"
|
||||
copy_bundle_assets "$BUNDLE_PATH"
|
||||
if [ -d "modules/ffmpegkit/ffmpegkit.framework" ]; then
|
||||
echo -e '\033[1m\033[32mBuilding RyukGram.bundle\033[0m'
|
||||
for fw in modules/ffmpegkit/*.framework; do
|
||||
cp -R "$fw" "$BUNDLE_PATH/"
|
||||
done
|
||||
LIBS="libavutil libavcodec libavformat libavfilter libavdevice libswresample libswscale"
|
||||
for lib in $LIBS; do
|
||||
mv "$BUNDLE_PATH/${lib}.framework" "$BUNDLE_PATH/${lib}_sci.framework"
|
||||
install_name_tool -id "@rpath/${lib}_sci.framework/${lib}" \
|
||||
"$BUNDLE_PATH/${lib}_sci.framework/${lib}"
|
||||
done
|
||||
for target in "$BUNDLE_PATH/ffmpegkit.framework/ffmpegkit" \
|
||||
"$BUNDLE_PATH"/libav*_sci.framework/libav* \
|
||||
"$BUNDLE_PATH"/libsw*_sci.framework/libsw*; do
|
||||
[ -f "$target" ] || continue
|
||||
for lib in $LIBS; do
|
||||
install_name_tool -change \
|
||||
"@rpath/${lib}.framework/${lib}" \
|
||||
"@rpath/${lib}_sci.framework/${lib}" \
|
||||
"$target" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
install_name_tool -add_rpath @loader_path/.. \
|
||||
"$BUNDLE_PATH/ffmpegkit.framework/ffmpegkit" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create IPA file
|
||||
TWEAKPATH=".theos/obj/debug/RyukGram.dylib"
|
||||
if [ "$2" == "--devquick" ]; then TWEAKPATH=""; fi
|
||||
|
||||
BUNDLE_ARG=""
|
||||
[ -d "$BUNDLE_PATH" ] && BUNDLE_ARG="$BUNDLE_PATH"
|
||||
|
||||
# Create IPA: cyan injects dylib + copies RyukGram.bundle to app root
|
||||
echo -e '\033[1m\033[32mCreating the IPA file...\033[0m'
|
||||
rm -f packages/RyukGram-sideloaded.ipa
|
||||
cyan -i "packages/${ipaFile}" -o packages/RyukGram-sideloaded.ipa -f $TWEAKPATH $FLEXPATH -c $COMPRESSION -m 15.0 -du
|
||||
cyan -i "packages/${ipaFile}" -o packages/RyukGram-sideloaded.ipa -f $TWEAKPATH $FLEXPATH $BUNDLE_ARG -c $COMPRESSION -m 15.0 -du
|
||||
|
||||
# Patch IPA for sideloading
|
||||
ipapatch --input "packages/RyukGram-sideloaded.ipa" --inplace --noconfirm
|
||||
# Inject Safari "Open in Instagram" extension into Payload/*.app/PlugIns/
|
||||
# before ipapatch re-signs, so instagram.com links open the app.
|
||||
APPEX_SRC="extensions/OpenInstagramSafariExtension.appex"
|
||||
if [ -d "$APPEX_SRC" ]; then
|
||||
echo -e '\033[1m\033[32mEmbedding Safari extension\033[0m'
|
||||
INJECT_TMP=$(mktemp -d)
|
||||
unzip -q packages/RyukGram-sideloaded.ipa -d "$INJECT_TMP"
|
||||
APP_DIR="$(find "$INJECT_TMP/Payload" -maxdepth 1 -type d -name '*.app' | head -1)"
|
||||
if [ -n "$APP_DIR" ]; then
|
||||
mkdir -p "$APP_DIR/PlugIns"
|
||||
rm -rf "$APP_DIR/PlugIns/OpenInstagramSafariExtension.appex"
|
||||
cp -R "$APPEX_SRC" "$APP_DIR/PlugIns/"
|
||||
( cd "$INJECT_TMP" && zip -qr -${COMPRESSION} ../repacked.ipa Payload )
|
||||
mv "$INJECT_TMP/../repacked.ipa" packages/RyukGram-sideloaded.ipa
|
||||
fi
|
||||
rm -rf "$INJECT_TMP"
|
||||
fi
|
||||
|
||||
run_ipapatch packages/RyukGram-sideloaded.ipa
|
||||
|
||||
echo -e "\033[1m\033[32mDone, enjoy RyukGram!\033[0m\n\nYou can find the ipa file at: $(pwd)/packages"
|
||||
|
||||
# Build rootless .deb
|
||||
# Build rootless .deb with FFmpegKit
|
||||
elif [ "$1" == "rootless" ];
|
||||
then
|
||||
|
||||
@@ -157,9 +350,20 @@ then
|
||||
export THEOS_PACKAGE_SCHEME=rootless
|
||||
make package
|
||||
|
||||
echo -e '\033[1m\033[32mInjecting RyukGram.bundle (localization + FFmpegKit) into deb\033[0m'
|
||||
cd packages
|
||||
BASE_DEB="$(ls -t *.deb | head -n1)"
|
||||
if [ -n "$BASE_DEB" ]; then
|
||||
inject_bundle_into_deb "$BASE_DEB"
|
||||
NEW_NAME="${BASE_DEB%.deb}-rootless.deb"
|
||||
mv "$BASE_DEB" "$NEW_NAME"
|
||||
fi
|
||||
cd ..
|
||||
[ -d "modules/ffmpegkit/ffmpegkit.framework" ] || echo -e '\033[0;33mFFmpegKit not found — deb built without FFmpegKit.\033[0m'
|
||||
|
||||
echo -e "\033[1m\033[32mDone, enjoy RyukGram!\033[0m\n\nYou can find the deb file at: $(pwd)/packages"
|
||||
|
||||
# Build rootful .deb
|
||||
# Build rootful .deb with FFmpegKit
|
||||
elif [ "$1" == "rootful" ];
|
||||
then
|
||||
|
||||
@@ -171,18 +375,143 @@ then
|
||||
unset THEOS_PACKAGE_SCHEME
|
||||
make package
|
||||
|
||||
echo -e '\033[1m\033[32mInjecting RyukGram.bundle (localization + FFmpegKit) into deb\033[0m'
|
||||
cd packages
|
||||
BASE_DEB="$(ls -t *.deb | head -n1)"
|
||||
if [ -n "$BASE_DEB" ]; then
|
||||
inject_bundle_into_deb "$BASE_DEB"
|
||||
NEW_NAME="${BASE_DEB%.deb}-rootful.deb"
|
||||
mv "$BASE_DEB" "$NEW_NAME"
|
||||
fi
|
||||
cd ..
|
||||
[ -d "modules/ffmpegkit/ffmpegkit.framework" ] || echo -e '\033[0;33mFFmpegKit not found — deb built without FFmpegKit.\033[0m'
|
||||
|
||||
echo -e "\033[1m\033[32mDone, enjoy RyukGram!\033[0m\n\nYou can find the deb file at: $(pwd)/packages"
|
||||
|
||||
# TrollStore build — .tipa is a renamed .ipa. Skip sideload re-sign; TS signs on-device.
|
||||
elif [ "$1" == "trollstore" ];
|
||||
then
|
||||
|
||||
HAS_FLEX=1
|
||||
if [ -z "$(ls -A modules/FLEXing 2>/dev/null)" ]; then
|
||||
HAS_FLEX=0
|
||||
fi
|
||||
|
||||
if [ "$HAS_FLEX" == "1" ]; then
|
||||
MAKEARGS='SIDELOAD=1'
|
||||
FLEXPATH='.theos/obj/debug/FLEXing.dylib .theos/obj/debug/libflex.dylib'
|
||||
else
|
||||
MAKEARGS=''
|
||||
FLEXPATH=''
|
||||
fi
|
||||
COMPRESSION=9
|
||||
|
||||
make clean 2>/dev/null || true
|
||||
rm -rf .theos
|
||||
|
||||
mkdir -p packages
|
||||
ipaFile="$(find ./packages/ -maxdepth 1 -type f \( -iname '*com.burbn.instagram*.ipa' -o -iname 'Instagram*.ipa' -o -iname '[0-9]*.ipa' \) ! -iname 'RyukGram*.ipa' -exec basename {} \; 2>/dev/null | head -1)"
|
||||
if [ -z "${ipaFile}" ]; then
|
||||
cwdIpa="$(find . -maxdepth 1 -type f \( -iname '*com.burbn.instagram*.ipa' -o -iname 'Instagram*.ipa' -o -iname '[0-9]*.ipa' \) 2>/dev/null | head -1)"
|
||||
if [ -n "$cwdIpa" ]; then
|
||||
mv "$cwdIpa" packages/
|
||||
ipaFile="$(basename "$cwdIpa")"
|
||||
fi
|
||||
fi
|
||||
if [ -z "${ipaFile}" ]; then
|
||||
echo -e '\033[1m\033[0;31mDecrypted Instagram IPA not found.\033[0m'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v cyan &> /dev/null; then
|
||||
echo -e '\033[1m\033[0;31mcyan not found. Install it with:\033[0m'
|
||||
echo ' pip install --force-reinstall https://github.com/asdfzxcvbn/pyzule-rw/archive/main.zip'
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v ipapatch &> /dev/null; then
|
||||
echo -e '\033[1m\033[0;31mipapatch not found. Install it from:\033[0m'
|
||||
echo ' https://github.com/asdfzxcvbn/ipapatch/releases/latest'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e '\033[1m\033[32mBuilding RyukGram tweak for TrollStore (.tipa)\033[0m'
|
||||
make $MAKEARGS
|
||||
cp .theos/obj/debug/RyukGram.dylib packages/RyukGram.dylib
|
||||
|
||||
echo -e '\033[1m\033[32mBuilding zxPluginsInject.dylib\033[0m'
|
||||
build_zxpi_dylib
|
||||
|
||||
BUNDLE_PATH="packages/RyukGram.bundle"
|
||||
rm -rf "$BUNDLE_PATH"
|
||||
mkdir -p "$BUNDLE_PATH"
|
||||
copy_localization_into_bundle "$BUNDLE_PATH"
|
||||
copy_bundle_assets "$BUNDLE_PATH"
|
||||
if [ -d "modules/ffmpegkit/ffmpegkit.framework" ]; then
|
||||
for fw in modules/ffmpegkit/*.framework; do
|
||||
cp -R "$fw" "$BUNDLE_PATH/"
|
||||
done
|
||||
LIBS="libavutil libavcodec libavformat libavfilter libavdevice libswresample libswscale"
|
||||
for lib in $LIBS; do
|
||||
mv "$BUNDLE_PATH/${lib}.framework" "$BUNDLE_PATH/${lib}_sci.framework"
|
||||
install_name_tool -id "@rpath/${lib}_sci.framework/${lib}" \
|
||||
"$BUNDLE_PATH/${lib}_sci.framework/${lib}"
|
||||
done
|
||||
for target in "$BUNDLE_PATH/ffmpegkit.framework/ffmpegkit" \
|
||||
"$BUNDLE_PATH"/libav*_sci.framework/libav* \
|
||||
"$BUNDLE_PATH"/libsw*_sci.framework/libsw*; do
|
||||
[ -f "$target" ] || continue
|
||||
for lib in $LIBS; do
|
||||
install_name_tool -change \
|
||||
"@rpath/${lib}.framework/${lib}" \
|
||||
"@rpath/${lib}_sci.framework/${lib}" \
|
||||
"$target" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
install_name_tool -add_rpath @loader_path/.. \
|
||||
"$BUNDLE_PATH/ffmpegkit.framework/ffmpegkit" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
TWEAKPATH=".theos/obj/debug/RyukGram.dylib"
|
||||
BUNDLE_ARG=""
|
||||
[ -d "$BUNDLE_PATH" ] && BUNDLE_ARG="$BUNDLE_PATH"
|
||||
|
||||
echo -e '\033[1m\033[32mCreating the TIPA file...\033[0m'
|
||||
rm -f packages/RyukGram-trollstore.tipa packages/RyukGram-trollstore.ipa
|
||||
cyan -i "packages/${ipaFile}" -o packages/RyukGram-trollstore.ipa -f $TWEAKPATH $FLEXPATH $BUNDLE_ARG -c $COMPRESSION -m 15.0 -du
|
||||
|
||||
# Embed Safari extension.
|
||||
APPEX_SRC="extensions/OpenInstagramSafariExtension.appex"
|
||||
if [ -d "$APPEX_SRC" ]; then
|
||||
echo -e '\033[1m\033[32mEmbedding Safari extension\033[0m'
|
||||
INJECT_TMP=$(mktemp -d)
|
||||
unzip -q packages/RyukGram-trollstore.ipa -d "$INJECT_TMP"
|
||||
APP_DIR="$(find "$INJECT_TMP/Payload" -maxdepth 1 -type d -name '*.app' | head -1)"
|
||||
if [ -n "$APP_DIR" ]; then
|
||||
mkdir -p "$APP_DIR/PlugIns"
|
||||
rm -rf "$APP_DIR/PlugIns/OpenInstagramSafariExtension.appex"
|
||||
cp -R "$APPEX_SRC" "$APP_DIR/PlugIns/"
|
||||
( cd "$INJECT_TMP" && zip -qr -${COMPRESSION} ../repacked.ipa Payload )
|
||||
mv "$INJECT_TMP/../repacked.ipa" packages/RyukGram-trollstore.ipa
|
||||
fi
|
||||
rm -rf "$INJECT_TMP"
|
||||
fi
|
||||
|
||||
run_ipapatch packages/RyukGram-trollstore.ipa
|
||||
|
||||
mv packages/RyukGram-trollstore.ipa packages/RyukGram-trollstore.tipa
|
||||
echo -e "\033[1m\033[32mDone!\033[0m\n\nTIPA at: $(pwd)/packages/RyukGram-trollstore.tipa"
|
||||
|
||||
else
|
||||
echo '+----------------------+'
|
||||
echo '|RyukGram Build Script |'
|
||||
echo '+----------------------+'
|
||||
echo
|
||||
echo 'Usage: ./build.sh <dylib/sideload/rootless/rootful>'
|
||||
echo 'Usage: ./build.sh <dylib/sideload/trollstore/rootless/rootful>'
|
||||
echo
|
||||
echo ' dylib - Build the dylib only (for Feather/manual injection)'
|
||||
echo ' sideload - Build a patched IPA (requires cyan + ipapatch + decrypted IPA)'
|
||||
echo ' rootless - Build a rootless .deb package'
|
||||
echo ' rootful - Build a rootful .deb package'
|
||||
echo ' dylib - Build the dylib only (for Feather/manual injection)'
|
||||
echo ' sideload - Build a patched IPA (requires cyan + decrypted IPA)'
|
||||
echo ' trollstore - Build a .tipa for TrollStore (requires cyan + decrypted IPA)'
|
||||
echo ' rootless - Build a rootless .deb package (with FFmpegKit)'
|
||||
echo ' rootful - Build a rootful .deb package (with FFmpegKit)'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Package: com.faroukbmiled.ryukgram
|
||||
Name: RyukGram
|
||||
Version: 1.1.5.1
|
||||
Version: 1.2.2
|
||||
Architecture: iphoneos-arm
|
||||
Description: A feature-rich tweak for Instagram on iOS, based on SCInsta
|
||||
Homepage: https://github.com/faroukbmiled/RyukGram
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extension_name": {
|
||||
"message": "Open in Instagram",
|
||||
"description": "The display name for the extension."
|
||||
},
|
||||
"extension_description": {
|
||||
"message": "Opens instagram.com links (profiles, posts, reels, stories, tags) in the Instagram app.",
|
||||
"description": "Description of what the extension does."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Redirect instagram.com web links into the native app.
|
||||
// Shipped inside RyukGram as a Safari web extension.
|
||||
|
||||
(function () {
|
||||
if (window.top !== window.self) return;
|
||||
if (sessionStorage.getItem("__sciOpenedApp")) return;
|
||||
|
||||
function urlFromLocation() {
|
||||
const path = window.location.pathname.split("/").filter(Boolean);
|
||||
if (path.length === 0) return null;
|
||||
|
||||
if (path[0] === "p" || path[0] === "reel") {
|
||||
const meta = document.querySelector("meta[property='al:ios:url']");
|
||||
if (meta && meta.getAttribute("content")) return meta.getAttribute("content");
|
||||
return path[1] ? `instagram://media?id=${path[1]}` : null;
|
||||
}
|
||||
|
||||
if (path[0] === "stories" && path[1]) {
|
||||
return `instagram://story?username=${path[1]}`;
|
||||
}
|
||||
|
||||
if (path[0] === "explore" && path[1] === "tags" && path[2]) {
|
||||
return `instagram://tag?name=${path[2]}`;
|
||||
}
|
||||
|
||||
if (path.length === 1) {
|
||||
return `instagram://user?username=${path[0]}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function openInApp() {
|
||||
const target = urlFromLocation();
|
||||
if (!target) return;
|
||||
sessionStorage.setItem("__sciOpenedApp", "1");
|
||||
window.location.href = target;
|
||||
}
|
||||
|
||||
openInApp();
|
||||
})();
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"default_locale": "en",
|
||||
|
||||
"name": "__MSG_extension_name__",
|
||||
"description": "__MSG_extension_description__",
|
||||
"version": "1.0",
|
||||
|
||||
"icons": {
|
||||
"48": "images/icon-48.png",
|
||||
"96": "images/icon-96.png",
|
||||
"128": "images/icon-128.png",
|
||||
"256": "images/icon-256.png",
|
||||
"512": "images/icon-512.png"
|
||||
},
|
||||
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
|
||||
"content_scripts": [{
|
||||
"js": [ "content.js" ],
|
||||
"matches": [ "*://*.instagram.com/*" ]
|
||||
}],
|
||||
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "images/toolbar-icon-16.png",
|
||||
"19": "images/toolbar-icon-19.png",
|
||||
"32": "images/toolbar-icon-32.png",
|
||||
"38": "images/toolbar-icon-38.png",
|
||||
"48": "images/toolbar-icon-48.png",
|
||||
"72": "images/toolbar-icon-72.png"
|
||||
}
|
||||
},
|
||||
|
||||
"permissions": [ ]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 220px;
|
||||
padding: 14px 16px;
|
||||
margin: 0;
|
||||
font-family: -apple-system, system-ui, sans-serif;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
<script type="module" src="popup.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="title">Open in RyukGram</div>
|
||||
<div class="subtitle">instagram.com links open in the app.</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
TARGET := iphone:clang:16.2:14.0
|
||||
ARCHS := arm64
|
||||
|
||||
include $(THEOS)/makefiles/common.mk
|
||||
|
||||
TWEAK_NAME := zxPluginsInject
|
||||
|
||||
$(TWEAK_NAME)_FILES := $(shell find src -type f -name "*.*m") ../fishhook/fishhook.c
|
||||
$(TWEAK_NAME)_CFLAGS := -fobjc-arc -Os
|
||||
$(TWEAK_NAME)_LOGOS_DEFAULT_GENERATOR := internal
|
||||
|
||||
include $(THEOS_MAKE_PATH)/tweak.mk
|
||||
@@ -0,0 +1,15 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
extern NSString *accessGroupId;
|
||||
extern NSString *bundleId;
|
||||
|
||||
extern void rebindSecFuncs();
|
||||
|
||||
extern BOOL createDirectoryIfNotExists(NSString *path);
|
||||
extern NSURL *getAppGroupPathIfExists();
|
||||
|
||||
@interface LSBundleProxy: NSObject
|
||||
@property(nonatomic, assign, readonly) NSDictionary *entitlements;
|
||||
@property(nonatomic, assign, readonly) NSDictionary *groupContainerURLs;
|
||||
+ (instancetype)bundleProxyForCurrentProcess;
|
||||
@end
|
||||
@@ -0,0 +1,38 @@
|
||||
#import <objc/runtime.h>
|
||||
|
||||
#import "Header.h"
|
||||
|
||||
BOOL createDirectoryIfNotExists(NSString *path) {
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
if ([fileManager fileExistsAtPath:path]) return YES;
|
||||
|
||||
NSError *error = nil;
|
||||
[fileManager createDirectoryAtPath:path
|
||||
withIntermediateDirectories:YES
|
||||
attributes:nil
|
||||
error:&error];
|
||||
return error == nil;
|
||||
}
|
||||
|
||||
NSURL *getAppGroupPathIfExists() {
|
||||
static NSURL *cachedAppGroupPath = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
|
||||
dispatch_once(&onceToken, ^{
|
||||
LSBundleProxy *bundleProxy = [objc_getClass("LSBundleProxy") bundleProxyForCurrentProcess];
|
||||
if (!bundleProxy) return;
|
||||
|
||||
NSDictionary *entitlements = bundleProxy.entitlements;
|
||||
if (![entitlements isKindOfClass:[NSDictionary class]]) return;
|
||||
|
||||
NSArray *appGroups = entitlements[@"com.apple.security.application-groups"];
|
||||
if (appGroups.count == 0) return;
|
||||
|
||||
NSDictionary *appGroupsPaths = bundleProxy.groupContainerURLs;
|
||||
if (![appGroupsPaths isKindOfClass:[NSDictionary class]]) return;
|
||||
|
||||
cachedAppGroupPath = appGroupsPaths[[appGroups firstObject]];
|
||||
});
|
||||
|
||||
return cachedAppGroupPath;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#import <Security/Security.h>
|
||||
|
||||
#import "Header.h"
|
||||
#import "../../fishhook/fishhook.h"
|
||||
|
||||
static OSStatus (*origSecItemAdd)(CFDictionaryRef attributes, CFTypeRef *result);
|
||||
static OSStatus (*origSecItemCopyMatching)(CFDictionaryRef query, CFTypeRef *result);
|
||||
static OSStatus (*origSecItemUpdate)(CFDictionaryRef query, CFDictionaryRef attributesToUpdate);
|
||||
static OSStatus (*origSecItemDelete)(CFDictionaryRef query);
|
||||
|
||||
static OSStatus zxSecItemAdd(CFDictionaryRef attributes, CFTypeRef *result) {
|
||||
NSMutableDictionary *mutableAttributes = [(__bridge NSDictionary *)attributes mutableCopy];
|
||||
mutableAttributes[(__bridge NSString *)kSecAttrAccessGroup] = accessGroupId;
|
||||
return origSecItemAdd((__bridge CFDictionaryRef)mutableAttributes, result);
|
||||
}
|
||||
|
||||
static OSStatus zxSecItemCopyMatching(CFDictionaryRef query, CFTypeRef *result) {
|
||||
NSMutableDictionary *mutableQuery = [(__bridge NSDictionary *)query mutableCopy];
|
||||
mutableQuery[(__bridge NSString *)kSecAttrAccessGroup] = accessGroupId;
|
||||
return origSecItemCopyMatching((__bridge CFDictionaryRef)mutableQuery, result);
|
||||
}
|
||||
|
||||
static OSStatus zxSecItemUpdate(CFDictionaryRef query, CFDictionaryRef attributesToUpdate) {
|
||||
NSMutableDictionary *mutableQuery = [(__bridge NSDictionary *)query mutableCopy];
|
||||
mutableQuery[(__bridge NSString *)kSecAttrAccessGroup] = accessGroupId;
|
||||
return origSecItemUpdate((__bridge CFDictionaryRef)mutableQuery, attributesToUpdate);
|
||||
}
|
||||
|
||||
static OSStatus zxSecItemDelete(CFDictionaryRef query) {
|
||||
NSMutableDictionary *mutableQuery = [(__bridge NSDictionary *)query mutableCopy];
|
||||
mutableQuery[(__bridge NSString *)kSecAttrAccessGroup] = accessGroupId;
|
||||
return origSecItemDelete((__bridge CFDictionaryRef)mutableQuery);
|
||||
}
|
||||
|
||||
void rebindSecFuncs() {
|
||||
struct rebinding rebinds[4] = {
|
||||
{"SecItemAdd", (void *)zxSecItemAdd, (void **)&origSecItemAdd},
|
||||
{"SecItemCopyMatching", (void *)zxSecItemCopyMatching, (void **)&origSecItemCopyMatching},
|
||||
{"SecItemUpdate", (void *)zxSecItemUpdate, (void **)&origSecItemUpdate},
|
||||
{"SecItemDelete", (void *)zxSecItemDelete, (void **)&origSecItemDelete}
|
||||
};
|
||||
rebind_symbols(rebinds, 4);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
#import "Header.h"
|
||||
|
||||
%hook CKContainer
|
||||
- (id)_setupWithContainerID:(id)a options:(id)b { return nil; }
|
||||
- (id)_initWithContainerIdentifier:(id)a { return nil; }
|
||||
%end
|
||||
|
||||
%hook CKEntitlements
|
||||
- (id)initWithEntitlementsDict:(NSDictionary *)entitlements {
|
||||
NSMutableDictionary *mutEntitlements = [entitlements mutableCopy];
|
||||
[mutEntitlements removeObjectForKey:@"com.apple.developer.icloud-container-environment"];
|
||||
[mutEntitlements removeObjectForKey:@"com.apple.developer.icloud-services"];
|
||||
return %orig([mutEntitlements copy]);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook NSFileManager
|
||||
- (NSURL *)containerURLForSecurityApplicationGroupIdentifier:(NSString *)groupIdentifier {
|
||||
if (NSURL *ourAppGroupURL = getAppGroupPathIfExists()) {
|
||||
NSURL *fakeAppGroupURL = [ourAppGroupURL URLByAppendingPathComponent:groupIdentifier];
|
||||
createDirectoryIfNotExists(fakeAppGroupURL.path);
|
||||
return fakeAppGroupURL;
|
||||
}
|
||||
|
||||
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
|
||||
NSString *fakePath = [[paths lastObject] stringByAppendingPathComponent:groupIdentifier];
|
||||
createDirectoryIfNotExists(fakePath);
|
||||
return [NSURL fileURLWithPath:fakePath];
|
||||
}
|
||||
%end
|
||||
|
||||
// Scoped to app-extension processes only. Appex needs the suite redirect so
|
||||
// it reads the group.* defaults the main app wrote (rich push previews depend
|
||||
// on it). Applying it in the main process breaks UI-dismiss flag persistence
|
||||
// on IG 423+ (Friends Map "Not now" reappears every launch).
|
||||
static BOOL sciIsAppExtensionProcess(void) {
|
||||
static BOOL cached = NO;
|
||||
static dispatch_once_t token;
|
||||
dispatch_once(&token, ^{
|
||||
cached = ([[NSBundle mainBundle] infoDictionary][@"NSExtension"] != nil);
|
||||
});
|
||||
return cached;
|
||||
}
|
||||
|
||||
%hook NSUserDefaults
|
||||
- (id)_initWithSuiteName:(NSString *)suiteName container:(NSURL *)container {
|
||||
if (!sciIsAppExtensionProcess()) return %orig(suiteName, container);
|
||||
|
||||
NSURL *appGroupURL = getAppGroupPathIfExists();
|
||||
if (!appGroupURL || ![suiteName hasPrefix:@"group"]) return %orig(suiteName, container);
|
||||
|
||||
if (NSURL *customContainerURL = [appGroupURL URLByAppendingPathComponent:suiteName]) {
|
||||
return %orig(suiteName, customContainerURL);
|
||||
}
|
||||
return %orig(suiteName, container);
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,29 @@
|
||||
#import "Header.h"
|
||||
|
||||
NSString *accessGroupId;
|
||||
NSString *bundleId;
|
||||
|
||||
static void setRequiredIDs() {
|
||||
NSDictionary *query = @{
|
||||
(__bridge NSString *)kSecClass: (__bridge NSString *)kSecClassGenericPassword,
|
||||
(__bridge NSString *)kSecAttrAccount: @"zxPluginsInjectGenericEntry",
|
||||
(__bridge NSString *)kSecAttrService: @"",
|
||||
(__bridge id)kSecReturnAttributes: (id)kCFBooleanTrue
|
||||
};
|
||||
|
||||
CFDictionaryRef result = nil;
|
||||
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&result);
|
||||
if (status == errSecItemNotFound) {
|
||||
status = SecItemAdd((__bridge CFDictionaryRef)query, (CFTypeRef *)&result);
|
||||
}
|
||||
if (status != errSecSuccess) return;
|
||||
|
||||
bundleId = [[NSBundle mainBundle] bundleIdentifier];
|
||||
accessGroupId = [(__bridge NSDictionary *)result objectForKey:(__bridge NSString *)kSecAttrAccessGroup];
|
||||
if (result) CFRelease(result);
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void init() {
|
||||
setRequiredIDs();
|
||||
rebindSecFuncs();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Downloads pre-built FFmpegKit frameworks for iOS arm64.
|
||||
# Called by build.sh before compilation if modules/ffmpegkit/ is empty.
|
||||
|
||||
set -e
|
||||
|
||||
DEST="$(dirname "$0")/../modules/ffmpegkit"
|
||||
MARKER="$DEST/.fetched"
|
||||
|
||||
# Skip if already fetched
|
||||
if [ -f "$MARKER" ]; then
|
||||
echo "[ffmpegkit] Already fetched, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# FFmpegKit 6.0 LTS — min-gpl variant (smallest, has x264)
|
||||
VERSION="6.0"
|
||||
VARIANT="min-gpl"
|
||||
URL="https://github.com/arthenica/ffmpeg-kit/releases/download/v${VERSION}/ffmpeg-kit-${VARIANT}-${VERSION}-ios-xcframework.zip"
|
||||
|
||||
echo "[ffmpegkit] Downloading FFmpegKit ${VERSION} (${VARIANT})..."
|
||||
TMPZIP=$(mktemp /tmp/ffmpegkit-XXXXXX.zip)
|
||||
curl -L -o "$TMPZIP" "$URL"
|
||||
|
||||
echo "[ffmpegkit] Extracting..."
|
||||
TMPDIR=$(mktemp -d /tmp/ffmpegkit-extract-XXXXXX)
|
||||
unzip -q "$TMPZIP" -d "$TMPDIR"
|
||||
|
||||
# XCFrameworks contain ios-arm64 slices — extract the .framework from each
|
||||
echo "[ffmpegkit] Installing frameworks..."
|
||||
for xcfw in "$TMPDIR"/*.xcframework; do
|
||||
NAME=$(basename "$xcfw" .xcframework)
|
||||
# Find the ios-arm64 framework slice
|
||||
ARM64_DIR=$(find "$xcfw" -type d -name "ios-arm64" -o -name "ios-arm64_armv7" 2>/dev/null | head -1)
|
||||
if [ -z "$ARM64_DIR" ]; then
|
||||
# Try the plain ios directory
|
||||
ARM64_DIR=$(find "$xcfw" -type d -name "*.framework" | head -1)
|
||||
ARM64_DIR=$(dirname "$ARM64_DIR")
|
||||
fi
|
||||
|
||||
if [ -d "$ARM64_DIR/${NAME}.framework" ]; then
|
||||
cp -R "$ARM64_DIR/${NAME}.framework" "$DEST/"
|
||||
echo " + ${NAME}.framework"
|
||||
else
|
||||
echo " ! ${NAME}.framework not found in xcframework"
|
||||
fi
|
||||
done
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$TMPZIP" "$TMPDIR"
|
||||
|
||||
touch "$MARKER"
|
||||
echo "[ffmpegkit] Done. Frameworks installed to modules/ffmpegkit/"
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Downloads FFmpegKit xcframeworks and extracts arm64 device frameworks.
|
||||
# Output: modules/ffmpegkit/{ffmpegkit,libav*,libsw*}.framework/
|
||||
|
||||
set -e
|
||||
|
||||
DEST="$(cd "$(dirname "$0")/.." && pwd)/modules/ffmpegkit"
|
||||
URL="https://github.com/luthviar/ffmpeg-kit-ios-full/releases/download/6.0/ffmpeg-kit-ios-full.zip"
|
||||
|
||||
mkdir -p "$DEST"
|
||||
|
||||
# Already set up?
|
||||
if [ -f "$DEST/ffmpegkit.framework/ffmpegkit" ]; then
|
||||
echo "[ffmpegkit] Already present, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[ffmpegkit] Downloading ffmpeg-kit-ios-full..."
|
||||
TMPDIR=$(mktemp -d)
|
||||
curl -L -o "$TMPDIR/ffmpegkit.zip" "$URL"
|
||||
|
||||
echo "[ffmpegkit] Extracting arm64 device frameworks..."
|
||||
unzip -q "$TMPDIR/ffmpegkit.zip" -d "$TMPDIR"
|
||||
|
||||
# Copy the ios-arm64 slice from each xcframework
|
||||
for xcfw in "$TMPDIR"/ffmpeg-kit-ios-full/*.xcframework; do
|
||||
NAME=$(basename "$xcfw" .xcframework)
|
||||
ARM64="$xcfw/ios-arm64/$NAME.framework"
|
||||
if [ -d "$ARM64" ]; then
|
||||
cp -R "$ARM64" "$DEST/"
|
||||
echo "[ffmpegkit] $NAME.framework"
|
||||
fi
|
||||
done
|
||||
|
||||
rm -rf "$TMPDIR"
|
||||
echo "[ffmpegkit] Done — $(ls -d "$DEST"/*.framework | wc -l | tr -d ' ') frameworks installed."
|
||||
@@ -0,0 +1,37 @@
|
||||
// SCIActionButton — wires a UIButton to the RyukGram action menu system.
|
||||
// Tap fires the default action; long-press opens the full context menu.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "SCIMediaActions.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef id _Nullable (^SCIActionMediaProvider)(UIView *sourceView);
|
||||
|
||||
@interface SCIActionButton : NSObject
|
||||
|
||||
/// Key for an optional dismiss callback block (void(^)(void)) stored on
|
||||
/// the button via objc_setAssociatedObject. Called when the context menu
|
||||
/// or UIMenu dismisses. Used by stories to resume playback.
|
||||
extern const void *kSCIDismissKey;
|
||||
|
||||
/// Configure an existing UIButton with RyukGram action-menu behavior.
|
||||
///
|
||||
/// `prefKey` is the NSUserDefaults key storing the default-tap choice
|
||||
/// (one of `menu`, `expand`, `download_share`, `download_photos`).
|
||||
+ (void)configureButton:(UIButton *)button
|
||||
context:(SCIActionContext)ctx
|
||||
prefKey:(NSString *)prefKey
|
||||
mediaProvider:(SCIActionMediaProvider)provider;
|
||||
|
||||
/// Build the deferred UIMenu for a given context + provider. Exposed so
|
||||
/// callers that already have their own UIButton wiring can reuse just the
|
||||
/// menu construction.
|
||||
+ (UIMenu *)deferredMenuForContext:(SCIActionContext)ctx
|
||||
fromView:(UIView *)sourceView
|
||||
mediaProvider:(SCIActionMediaProvider)provider;
|
||||
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,181 @@
|
||||
#import "SCIActionButton.h"
|
||||
#import "SCIActionMenu.h"
|
||||
#import "SCIRepostSheet.h"
|
||||
#import "../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
// Associated-object keys for per-button config.
|
||||
static const void *kSCICtxKey = &kSCICtxKey;
|
||||
static const void *kSCIProviderKey = &kSCIProviderKey;
|
||||
static const void *kSCIPrefKey = &kSCIPrefKey;
|
||||
const void *kSCIDismissKey = &kSCIDismissKey;
|
||||
|
||||
|
||||
@interface SCIActionButton () <UIContextMenuInteractionDelegate>
|
||||
@end
|
||||
|
||||
@implementation SCIActionButton
|
||||
|
||||
// Singleton delegate for UIContextMenuInteraction.
|
||||
+ (instancetype)shared {
|
||||
static SCIActionButton *s;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ s = [SCIActionButton new]; });
|
||||
return s;
|
||||
}
|
||||
|
||||
+ (UIMenu *)deferredMenuForContext:(SCIActionContext)ctx
|
||||
fromView:(UIView *)sourceView
|
||||
mediaProvider:(SCIActionMediaProvider)provider {
|
||||
__weak UIView *weakSource = sourceView;
|
||||
SCIActionMediaProvider capturedProvider = [provider copy];
|
||||
|
||||
UIDeferredMenuElement *deferred = [UIDeferredMenuElement
|
||||
elementWithUncachedProvider:^(void (^completion)(NSArray<UIMenuElement *> * _Nonnull)) {
|
||||
UIView *view = weakSource;
|
||||
id media = (view && capturedProvider) ? capturedProvider(view) : nil;
|
||||
NSArray *actions = [SCIMediaActions actionsForContext:ctx
|
||||
media:media
|
||||
fromView:view];
|
||||
UIMenu *built = [SCIActionMenu buildMenuWithActions:actions];
|
||||
completion(built.children);
|
||||
}];
|
||||
|
||||
return [UIMenu menuWithTitle:@""
|
||||
image:nil
|
||||
identifier:nil
|
||||
options:0
|
||||
children:@[deferred]];
|
||||
}
|
||||
|
||||
+ (void)configureButton:(UIButton *)button
|
||||
context:(SCIActionContext)ctx
|
||||
prefKey:(NSString *)prefKey
|
||||
mediaProvider:(SCIActionMediaProvider)provider {
|
||||
if (!button) return;
|
||||
|
||||
// Stash config on the button.
|
||||
objc_setAssociatedObject(button, kSCICtxKey, @(ctx), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
objc_setAssociatedObject(button, kSCIProviderKey, [provider copy], OBJC_ASSOCIATION_COPY_NONATOMIC);
|
||||
objc_setAssociatedObject(button, kSCIPrefKey, [prefKey copy], OBJC_ASSOCIATION_COPY_NONATOMIC);
|
||||
|
||||
// Read default tap mode fresh.
|
||||
NSString *defaultTap = [SCIUtils getStringPref:prefKey];
|
||||
if (!defaultTap.length) defaultTap = @"menu";
|
||||
|
||||
// Remove previous wiring to stay idempotent.
|
||||
[button removeTarget:nil action:NULL forControlEvents:UIControlEventTouchUpInside];
|
||||
for (id<UIInteraction> it in [button.interactions copy]) {
|
||||
if ([(id)it isKindOfClass:[UIContextMenuInteraction class]]) {
|
||||
[button removeInteraction:it];
|
||||
}
|
||||
}
|
||||
|
||||
if ([defaultTap isEqualToString:@"menu"]) {
|
||||
// Tap opens menu natively.
|
||||
button.menu = [self deferredMenuForContext:ctx fromView:button mediaProvider:provider];
|
||||
button.showsMenuAsPrimaryAction = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
// Tap fires dedicated action; long-press opens menu.
|
||||
button.showsMenuAsPrimaryAction = NO;
|
||||
button.menu = nil;
|
||||
[button addTarget:[self shared]
|
||||
action:@selector(sciTapHandler:)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
|
||||
UIContextMenuInteraction *interaction =
|
||||
[[UIContextMenuInteraction alloc] initWithDelegate:[self shared]];
|
||||
[button addInteraction:interaction];
|
||||
}
|
||||
|
||||
// Haptic + scale-bounce feedback.
|
||||
+ (void)bounceButton:(UIView *)view {
|
||||
UIImpactFeedbackGenerator *haptic =
|
||||
[[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
[UIView animateWithDuration:0.1
|
||||
animations:^{ view.transform = CGAffineTransformMakeScale(0.82, 0.82); }
|
||||
completion:^(BOOL _) {
|
||||
[UIView animateWithDuration:0.1 animations:^{
|
||||
view.transform = CGAffineTransformIdentity;
|
||||
}];
|
||||
}];
|
||||
}
|
||||
|
||||
// Default-tap handler.
|
||||
- (void)sciTapHandler:(UIButton *)sender {
|
||||
[SCIActionButton bounceButton:sender];
|
||||
|
||||
NSNumber *ctxNum = objc_getAssociatedObject(sender, kSCICtxKey);
|
||||
SCIActionMediaProvider provider = objc_getAssociatedObject(sender, kSCIProviderKey);
|
||||
NSString *prefKey = objc_getAssociatedObject(sender, kSCIPrefKey);
|
||||
if (!ctxNum || !provider) return;
|
||||
|
||||
NSString *tap = [SCIUtils getStringPref:prefKey];
|
||||
if (!tap.length) tap = @"menu";
|
||||
id media = provider(sender);
|
||||
if (media == (id)kCFNull) return;
|
||||
|
||||
SCIActionContext tapCtx = (SCIActionContext)ctxNum.integerValue;
|
||||
NSString *tapCtxLabel = [SCIMediaActions contextLabelForContext:tapCtx];
|
||||
|
||||
if ([tap isEqualToString:@"expand"]) {
|
||||
[SCIMediaActions expandMedia:media fromView:sender caption:nil];
|
||||
} else if ([tap isEqualToString:@"download_share"]) {
|
||||
[SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:media contextLabel:tapCtxLabel]];
|
||||
[SCIMediaActions downloadAndShareMedia:media];
|
||||
} else if ([tap isEqualToString:@"download_photos"]) {
|
||||
[SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:media contextLabel:tapCtxLabel]];
|
||||
[SCIMediaActions downloadAndSaveMedia:media];
|
||||
} else if ([tap isEqualToString:@"copy_link"]) {
|
||||
[SCIMediaActions copyURLForMedia:media];
|
||||
} else if ([tap isEqualToString:@"repost"]) {
|
||||
NSURL *vidURL = [SCIUtils getVideoUrlForMedia:(id)media];
|
||||
NSURL *imgURL = [SCIUtils getPhotoUrlForMedia:(id)media];
|
||||
[SCIRepostSheet repostWithVideoURL:vidURL photoURL:imgURL];
|
||||
} else if ([tap isEqualToString:@"view_mentions"]) {
|
||||
UIViewController *host = [SCIUtils nearestViewControllerForView:sender];
|
||||
if (host) {
|
||||
extern void sciShowStoryMentions(UIViewController *, UIView *);
|
||||
sciShowStoryMentions(host, sender);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIContextMenuInteractionDelegate
|
||||
|
||||
- (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction
|
||||
configurationForMenuAtLocation:(CGPoint)location {
|
||||
UIView *view = interaction.view;
|
||||
NSNumber *ctxNum = objc_getAssociatedObject(view, kSCICtxKey);
|
||||
SCIActionMediaProvider provider = objc_getAssociatedObject(view, kSCIProviderKey);
|
||||
if (!ctxNum || !provider) return nil;
|
||||
SCIActionContext ctx = (SCIActionContext)ctxNum.integerValue;
|
||||
|
||||
return [UIContextMenuConfiguration
|
||||
configurationWithIdentifier:nil
|
||||
previewProvider:nil
|
||||
actionProvider:^UIMenu * _Nullable(NSArray<UIMenuElement *> * _Nonnull suggested) {
|
||||
return [SCIActionButton deferredMenuForContext:ctx
|
||||
fromView:view
|
||||
mediaProvider:provider];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)contextMenuInteraction:(UIContextMenuInteraction *)interaction
|
||||
willEndForConfiguration:(UIContextMenuConfiguration *)configuration
|
||||
animator:(id<UIContextMenuInteractionAnimating>)animator {
|
||||
UIView *view = interaction.view;
|
||||
void (^dismiss)(void) = objc_getAssociatedObject(view, kSCIDismissKey);
|
||||
if (dismiss) {
|
||||
if (animator) {
|
||||
[animator addCompletion:^{ dismiss(); }];
|
||||
} else {
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,48 @@
|
||||
// SCIActionMenu — reusable action menu model + UIMenu builder.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// One menu entry. Either a leaf (has handler) or a submenu (has children).
|
||||
@interface SCIAction : NSObject
|
||||
@property (nonatomic, copy, readonly) NSString *title;
|
||||
@property (nonatomic, copy, readonly, nullable) NSString *subtitle;
|
||||
@property (nonatomic, copy, readonly, nullable) NSString *systemIconName;
|
||||
@property (nonatomic, copy, readonly, nullable) void (^handler)(void);
|
||||
@property (nonatomic, copy, readonly, nullable) NSArray<SCIAction *> *children;
|
||||
@property (nonatomic, assign, readonly) BOOL destructive;
|
||||
@property (nonatomic, assign, readonly) BOOL isSeparator;
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
icon:(nullable NSString *)icon
|
||||
handler:(void(^)(void))handler;
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
subtitle:(nullable NSString *)subtitle
|
||||
icon:(nullable NSString *)icon
|
||||
destructive:(BOOL)destructive
|
||||
handler:(void(^)(void))handler;
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
icon:(nullable NSString *)icon
|
||||
children:(NSArray<SCIAction *> *)children;
|
||||
|
||||
/// A visual group break. Rendered as an inline submenu divider in UIMenu.
|
||||
+ (instancetype)separator;
|
||||
@end
|
||||
|
||||
|
||||
@interface SCIActionMenu : NSObject
|
||||
|
||||
/// Build a UIMenu from an array of SCIAction. Consecutive actions between
|
||||
/// `separator` markers are grouped into inline submenus so they render as
|
||||
/// divided sections (standard iOS menu aesthetic).
|
||||
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions;
|
||||
|
||||
/// Build a UIMenu with a header title shown at the top of the menu.
|
||||
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions title:(nullable NSString *)title;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,132 @@
|
||||
#import "SCIActionMenu.h"
|
||||
|
||||
#pragma mark - SCIAction
|
||||
|
||||
@interface SCIAction ()
|
||||
@property (nonatomic, copy, readwrite) NSString *title;
|
||||
@property (nonatomic, copy, readwrite, nullable) NSString *subtitle;
|
||||
@property (nonatomic, copy, readwrite, nullable) NSString *systemIconName;
|
||||
@property (nonatomic, copy, readwrite, nullable) void (^handler)(void);
|
||||
@property (nonatomic, copy, readwrite, nullable) NSArray<SCIAction *> *children;
|
||||
@property (nonatomic, assign, readwrite) BOOL destructive;
|
||||
@property (nonatomic, assign, readwrite) BOOL isSeparator;
|
||||
@end
|
||||
|
||||
@implementation SCIAction
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
icon:(NSString *)icon
|
||||
handler:(void(^)(void))handler {
|
||||
return [self actionWithTitle:title subtitle:nil icon:icon destructive:NO handler:handler];
|
||||
}
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
subtitle:(NSString *)subtitle
|
||||
icon:(NSString *)icon
|
||||
destructive:(BOOL)destructive
|
||||
handler:(void(^)(void))handler {
|
||||
SCIAction *a = [SCIAction new];
|
||||
a.title = title ?: @"";
|
||||
a.subtitle = subtitle;
|
||||
a.systemIconName = icon;
|
||||
a.handler = handler;
|
||||
a.destructive = destructive;
|
||||
return a;
|
||||
}
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
icon:(NSString *)icon
|
||||
children:(NSArray<SCIAction *> *)children {
|
||||
SCIAction *a = [SCIAction new];
|
||||
a.title = title ?: @"";
|
||||
a.systemIconName = icon;
|
||||
a.children = [children copy];
|
||||
return a;
|
||||
}
|
||||
|
||||
+ (instancetype)separator {
|
||||
SCIAction *a = [SCIAction new];
|
||||
a.isSeparator = YES;
|
||||
return a;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
#pragma mark - SCIActionMenu
|
||||
|
||||
@implementation SCIActionMenu
|
||||
|
||||
+ (UIImage *)imageForIcon:(NSString *)name {
|
||||
if (!name.length) return nil;
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:16 weight:UIImageSymbolWeightRegular];
|
||||
return [UIImage systemImageNamed:name withConfiguration:cfg];
|
||||
}
|
||||
|
||||
// Convert SCIAction to UIMenuElement.
|
||||
+ (UIMenuElement *)elementForAction:(SCIAction *)action {
|
||||
if (action.children.count) {
|
||||
NSMutableArray<UIMenuElement *> *kids = [NSMutableArray arrayWithCapacity:action.children.count];
|
||||
for (SCIAction *child in action.children) {
|
||||
UIMenuElement *el = [self elementForAction:child];
|
||||
if (el) [kids addObject:el];
|
||||
}
|
||||
return [UIMenu menuWithTitle:action.title
|
||||
image:[self imageForIcon:action.systemIconName]
|
||||
identifier:nil
|
||||
options:0
|
||||
children:kids];
|
||||
}
|
||||
|
||||
UIAction *ua = [UIAction actionWithTitle:action.title
|
||||
image:[self imageForIcon:action.systemIconName]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction * _Nonnull a) {
|
||||
if (action.handler) action.handler();
|
||||
}];
|
||||
|
||||
if (@available(iOS 15.0, *)) {
|
||||
if (action.subtitle.length) ua.subtitle = action.subtitle;
|
||||
}
|
||||
if (action.destructive) ua.attributes = UIMenuElementAttributesDestructive;
|
||||
return ua;
|
||||
}
|
||||
|
||||
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions {
|
||||
return [self buildMenuWithActions:actions title:nil];
|
||||
}
|
||||
|
||||
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions title:(NSString *)title {
|
||||
// Group actions between separators into inline submenus.
|
||||
NSMutableArray<UIMenuElement *> *top = [NSMutableArray array];
|
||||
NSMutableArray<UIMenuElement *> *currentGroup = [NSMutableArray array];
|
||||
|
||||
void (^flush)(void) = ^{
|
||||
if (currentGroup.count == 0) return;
|
||||
UIMenu *group = [UIMenu menuWithTitle:@""
|
||||
image:nil
|
||||
identifier:nil
|
||||
options:UIMenuOptionsDisplayInline
|
||||
children:[currentGroup copy]];
|
||||
[top addObject:group];
|
||||
[currentGroup removeAllObjects];
|
||||
};
|
||||
|
||||
for (SCIAction *a in actions) {
|
||||
if (a.isSeparator) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
UIMenuElement *el = [self elementForAction:a];
|
||||
if (el) [currentGroup addObject:el];
|
||||
}
|
||||
flush();
|
||||
|
||||
return [UIMenu menuWithTitle:title ?: @""
|
||||
image:nil
|
||||
identifier:nil
|
||||
options:0
|
||||
children:[top copy]];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,121 @@
|
||||
// SCIMediaActions — shared media extraction + action handlers for the action menu.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "../InstagramHeaders.h"
|
||||
#import "../Downloader/Download.h"
|
||||
#import "SCIActionMenu.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// Where the action is being invoked from. Used to target settings entries
|
||||
/// and to pick context-specific language in HUDs.
|
||||
typedef NS_ENUM(NSInteger, SCIActionContext) {
|
||||
SCIActionContextFeed,
|
||||
SCIActionContextReels,
|
||||
SCIActionContextStories,
|
||||
};
|
||||
|
||||
@interface SCIMediaActions : NSObject
|
||||
|
||||
// MARK: - Filename naming
|
||||
|
||||
// `@username_context_yyyyMMdd_HHmmss` (sanitized). UUID fallback on failure.
|
||||
+ (NSString *)filenameStemForMedia:(nullable id)media contextLabel:(NSString *)ctxLabel;
|
||||
|
||||
// "feed" / "reels" / "stories".
|
||||
+ (NSString *)contextLabelForContext:(SCIActionContext)ctx;
|
||||
|
||||
// Stem read by the download + mux write sites to name output files.
|
||||
+ (nullable NSString *)currentFilenameStem;
|
||||
+ (void)setCurrentFilenameStem:(nullable NSString *)stem;
|
||||
|
||||
// MARK: - Media extraction
|
||||
|
||||
/// Return the post's caption string. Tries selectors first, falls back to
|
||||
/// reading `_fieldCache[@"caption"][@"text"]`.
|
||||
+ (nullable NSString *)captionForMedia:(id)media;
|
||||
|
||||
/// YES if the media is a carousel (multi-photo/video sidecar).
|
||||
+ (BOOL)isCarouselMedia:(id)media;
|
||||
|
||||
/// Ordered children of a carousel IGMedia. Empty array for non-carousels.
|
||||
+ (NSArray *)carouselChildrenForMedia:(id)media;
|
||||
|
||||
/// YES if the media has an audio track (`has_audio` fieldCache == 1).
|
||||
+ (BOOL)mediaHasAudio:(id)media;
|
||||
|
||||
/// Download the raw photo URL, skipping any video route.
|
||||
+ (void)downloadPhotoOnlyForMedia:(id)media action:(DownloadAction)action;
|
||||
|
||||
/// Extract the audio-only track from the DASH manifest via FFmpeg. Photos
|
||||
/// library can't hold audio, so both actions end at the share sheet.
|
||||
+ (void)downloadAudioOnlyForMedia:(id)media action:(DownloadAction)action;
|
||||
|
||||
/// Best URL for a single (non-carousel) media item. Prefers video URL, falls
|
||||
/// back to photo URL. Returns nil if nothing extractable.
|
||||
+ (nullable NSURL *)bestURLForMedia:(id)media;
|
||||
|
||||
/// Cover/poster image URL for a video-type media (first frame). Works for
|
||||
/// reels, feed videos, and story videos.
|
||||
+ (nullable NSURL *)coverURLForMedia:(id)media;
|
||||
|
||||
// MARK: - Primary actions (each directly triggerable from a menu entry)
|
||||
|
||||
/// Present the media in the native QLPreview UI. Video URLs download first,
|
||||
/// images preview directly. Optional caption is shown as a subtitle.
|
||||
+ (void)expandMedia:(id)media
|
||||
fromView:(UIView *)sourceView
|
||||
caption:(nullable NSString *)caption;
|
||||
|
||||
/// Download the best URL for the media and hand off via share sheet.
|
||||
+ (void)downloadAndShareMedia:(id)media;
|
||||
|
||||
/// Download the best URL for the media and save to Photos (respects album pref).
|
||||
+ (void)downloadAndSaveMedia:(id)media;
|
||||
|
||||
/// Copy the direct CDN URL for the media to the clipboard.
|
||||
+ (void)copyURLForMedia:(id)media;
|
||||
|
||||
/// Copy the post caption to the clipboard.
|
||||
+ (void)copyCaptionForMedia:(id)media;
|
||||
|
||||
/// Trigger Instagram's native repost flow for the given context's currently
|
||||
/// visible UFI bar. Uses the existing button ivars to avoid reimplementing.
|
||||
+ (void)triggerRepostForContext:(SCIActionContext)ctx sourceView:(UIView *)sourceView;
|
||||
|
||||
/// Open the RyukGram settings page for the given context.
|
||||
+ (void)openSettingsForContext:(SCIActionContext)ctx fromView:(UIView *)sourceView;
|
||||
|
||||
// MARK: - Carousel bulk actions
|
||||
|
||||
/// Download every child of a carousel and share as a batch.
|
||||
+ (void)downloadAllAndShareMedia:(id)carouselMedia;
|
||||
|
||||
/// Download every child of a carousel and save to Photos.
|
||||
+ (void)downloadAllAndSaveMedia:(id)carouselMedia;
|
||||
|
||||
/// Copy newline-joined CDN URLs for every child of a carousel.
|
||||
+ (void)copyAllURLsForMedia:(id)carouselMedia;
|
||||
|
||||
// MARK: - Menu builders
|
||||
|
||||
// MARK: - Bulk URL download helpers
|
||||
|
||||
/// Download an array of URLs in parallel, show pill, call done with file URLs.
|
||||
+ (void)bulkDownloadURLs:(NSArray<NSURL *> *)urls
|
||||
title:(NSString *)title
|
||||
done:(void(^)(NSArray<NSURL *> *fileURLs))done;
|
||||
|
||||
/// Save an array of local file URLs to Photos (sequential, respects album pref).
|
||||
+ (void)bulkSaveFiles:(NSArray<NSURL *> *)files;
|
||||
|
||||
/// Build the full action menu for the given context + media + default tap.
|
||||
/// If `defaultTap` is provided and non-menu, the builder may reorder or skip
|
||||
/// its matching leaf so it's visible in the full menu.
|
||||
+ (NSArray<SCIAction *> *)actionsForContext:(SCIActionContext)ctx
|
||||
media:(nullable id)media
|
||||
fromView:(UIView *)sourceView;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,24 @@
|
||||
// SCIMediaViewer — full-screen media viewer. Supports single items and carousels.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
/// One media item to display.
|
||||
@interface SCIMediaViewerItem : NSObject
|
||||
@property (nonatomic, strong) NSURL *videoURL; // nil for photos
|
||||
@property (nonatomic, strong) NSURL *photoURL; // nil for videos
|
||||
@property (nonatomic, copy) NSString *caption;
|
||||
+ (instancetype)itemWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption;
|
||||
@end
|
||||
|
||||
@interface SCIMediaViewer : NSObject
|
||||
|
||||
/// Show a single media item.
|
||||
+ (void)showItem:(SCIMediaViewerItem *)item;
|
||||
|
||||
/// Show multiple items (carousel). Starts at the given index.
|
||||
+ (void)showItems:(NSArray<SCIMediaViewerItem *> *)items startIndex:(NSUInteger)index;
|
||||
|
||||
/// Convenience: auto-detect video vs photo for a single item.
|
||||
+ (void)showWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,470 @@
|
||||
#import "SCIMediaViewer.h"
|
||||
#import "../Utils.h"
|
||||
#import "../SCIImageCache.h"
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <AVKit/AVKit.h>
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Data model
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@implementation SCIMediaViewerItem
|
||||
+ (instancetype)itemWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption {
|
||||
SCIMediaViewerItem *i = [SCIMediaViewerItem new];
|
||||
i.videoURL = videoURL;
|
||||
i.photoURL = photoURL;
|
||||
i.caption = caption;
|
||||
return i;
|
||||
}
|
||||
@end
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Single photo page
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@interface _SCIPhotoPageVC : UIViewController <UIScrollViewDelegate>
|
||||
@property (nonatomic, strong) NSURL *photoURL;
|
||||
@property (nonatomic, strong) UIScrollView *scrollView;
|
||||
@property (nonatomic, strong) UIImageView *imageView;
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *spinner;
|
||||
@end
|
||||
|
||||
@implementation _SCIPhotoPageVC
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor blackColor];
|
||||
|
||||
self.scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];
|
||||
self.scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
self.scrollView.delegate = self;
|
||||
self.scrollView.minimumZoomScale = 1.0;
|
||||
self.scrollView.maximumZoomScale = 5.0;
|
||||
self.scrollView.showsVerticalScrollIndicator = NO;
|
||||
self.scrollView.showsHorizontalScrollIndicator = NO;
|
||||
[self.view addSubview:self.scrollView];
|
||||
|
||||
self.imageView = [[UIImageView alloc] initWithFrame:self.scrollView.bounds];
|
||||
self.imageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
self.imageView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
[self.scrollView addSubview:self.imageView];
|
||||
|
||||
self.spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||
self.spinner.color = [UIColor whiteColor];
|
||||
self.spinner.center = self.view.center;
|
||||
self.spinner.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin
|
||||
| UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin;
|
||||
[self.view addSubview:self.spinner];
|
||||
[self.spinner startAnimating];
|
||||
|
||||
[SCIImageCache loadImageFromURL:self.photoURL completion:^(UIImage *img) {
|
||||
[self.spinner stopAnimating];
|
||||
if (img) self.imageView.image = img;
|
||||
}];
|
||||
|
||||
// Double-tap to zoom
|
||||
UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleDoubleTap:)];
|
||||
doubleTap.numberOfTapsRequired = 2;
|
||||
[self.scrollView addGestureRecognizer:doubleTap];
|
||||
}
|
||||
|
||||
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)sv { return self.imageView; }
|
||||
|
||||
- (void)handleDoubleTap:(UITapGestureRecognizer *)gr {
|
||||
if (self.scrollView.zoomScale > 1.0) {
|
||||
[self.scrollView setZoomScale:1.0 animated:YES];
|
||||
} else {
|
||||
CGPoint pt = [gr locationInView:self.imageView];
|
||||
CGRect rect = CGRectMake(pt.x - 50, pt.y - 50, 100, 100);
|
||||
[self.scrollView zoomToRect:rect animated:YES];
|
||||
}
|
||||
}
|
||||
|
||||
- (UIImage *)currentImage { return self.imageView.image; }
|
||||
|
||||
@end
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Single video page
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@interface _SCIVideoPageVC : UIViewController
|
||||
@property (nonatomic, strong) NSURL *videoURL;
|
||||
@property (nonatomic, strong) AVPlayerViewController *playerVC;
|
||||
@end
|
||||
|
||||
@implementation _SCIVideoPageVC
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor blackColor];
|
||||
|
||||
AVPlayer *player = [AVPlayer playerWithURL:self.videoURL];
|
||||
self.playerVC = [[AVPlayerViewController alloc] init];
|
||||
self.playerVC.player = player;
|
||||
self.playerVC.showsPlaybackControls = YES;
|
||||
|
||||
[self addChildViewController:self.playerVC];
|
||||
self.playerVC.view.frame = self.view.bounds;
|
||||
self.playerVC.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
[self.view addSubview:self.playerVC.view];
|
||||
[self.playerVC didMoveToParentViewController:self];
|
||||
|
||||
[player play];
|
||||
}
|
||||
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
[super viewWillDisappear:animated];
|
||||
[self.playerVC.player pause];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Container VC (PageViewController-based)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@interface _SCIMediaViewerContainerVC : UIViewController <UIPageViewControllerDataSource, UIPageViewControllerDelegate, UIGestureRecognizerDelegate>
|
||||
@property (nonatomic, strong) NSArray<SCIMediaViewerItem *> *items;
|
||||
@property (nonatomic, assign) NSUInteger currentIndex;
|
||||
@property (nonatomic, strong) UIPageViewController *pageVC;
|
||||
@property (nonatomic, strong) UIView *topBar;
|
||||
@property (nonatomic, strong) UIButton *closeBtn;
|
||||
@property (nonatomic, strong) UILabel *counterLabel;
|
||||
@property (nonatomic, strong) UIButton *shareBtn;
|
||||
@property (nonatomic, strong) UIView *bottomBar;
|
||||
@property (nonatomic, strong) UILabel *captionLabel;
|
||||
@property (nonatomic, assign) BOOL chromeVisible;
|
||||
@property (nonatomic, assign) BOOL captionExpanded;
|
||||
@end
|
||||
|
||||
@implementation _SCIMediaViewerContainerVC
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor blackColor];
|
||||
self.chromeVisible = YES;
|
||||
|
||||
// Page view controller
|
||||
self.pageVC = [[UIPageViewController alloc]
|
||||
initWithTransitionStyle:UIPageViewControllerTransitionStyleScroll
|
||||
navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal
|
||||
options:nil];
|
||||
self.pageVC.dataSource = self.items.count > 1 ? self : nil;
|
||||
self.pageVC.delegate = self;
|
||||
|
||||
UIViewController *firstPage = [self viewControllerForIndex:self.currentIndex];
|
||||
if (firstPage) [self.pageVC setViewControllers:@[firstPage] direction:UIPageViewControllerNavigationDirectionForward animated:NO completion:nil];
|
||||
|
||||
[self addChildViewController:self.pageVC];
|
||||
self.pageVC.view.frame = self.view.bounds;
|
||||
self.pageVC.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
[self.view addSubview:self.pageVC.view];
|
||||
[self.pageVC didMoveToParentViewController:self];
|
||||
|
||||
// Top bar
|
||||
self.topBar = [[UIView alloc] init];
|
||||
self.topBar.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:self.topBar];
|
||||
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:17 weight:UIImageSymbolWeightSemibold];
|
||||
|
||||
self.closeBtn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
[self.closeBtn setImage:[UIImage systemImageNamed:@"xmark" withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
self.closeBtn.tintColor = [UIColor whiteColor];
|
||||
self.closeBtn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.closeBtn addTarget:self action:@selector(closeTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.topBar addSubview:self.closeBtn];
|
||||
|
||||
self.shareBtn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
[self.shareBtn setImage:[UIImage systemImageNamed:@"square.and.arrow.up" withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
self.shareBtn.tintColor = [UIColor whiteColor];
|
||||
self.shareBtn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.shareBtn addTarget:self action:@selector(shareTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.topBar addSubview:self.shareBtn];
|
||||
|
||||
self.counterLabel = [[UILabel alloc] init];
|
||||
self.counterLabel.textColor = [UIColor whiteColor];
|
||||
self.counterLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
|
||||
self.counterLabel.textAlignment = NSTextAlignmentCenter;
|
||||
self.counterLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.topBar addSubview:self.counterLabel];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.topBar.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
|
||||
[self.topBar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[self.topBar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[self.topBar.heightAnchor constraintEqualToConstant:44],
|
||||
[self.closeBtn.leadingAnchor constraintEqualToAnchor:self.topBar.leadingAnchor constant:16],
|
||||
[self.closeBtn.centerYAnchor constraintEqualToAnchor:self.topBar.centerYAnchor],
|
||||
[self.shareBtn.trailingAnchor constraintEqualToAnchor:self.topBar.trailingAnchor constant:-16],
|
||||
[self.shareBtn.centerYAnchor constraintEqualToAnchor:self.topBar.centerYAnchor],
|
||||
[self.counterLabel.centerXAnchor constraintEqualToAnchor:self.topBar.centerXAnchor],
|
||||
[self.counterLabel.centerYAnchor constraintEqualToAnchor:self.topBar.centerYAnchor],
|
||||
]];
|
||||
|
||||
// Bottom bar (caption — tap to expand/collapse)
|
||||
self.bottomBar = [[UIView alloc] init];
|
||||
self.bottomBar.backgroundColor = [UIColor colorWithWhite:0 alpha:0.6];
|
||||
self.bottomBar.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:self.bottomBar];
|
||||
|
||||
self.captionLabel = [[UILabel alloc] init];
|
||||
self.captionLabel.textColor = [UIColor whiteColor];
|
||||
self.captionLabel.font = [UIFont systemFontOfSize:14];
|
||||
self.captionLabel.numberOfLines = 3; // collapsed
|
||||
self.captionLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
self.captionLabel.userInteractionEnabled = YES;
|
||||
[self.bottomBar addSubview:self.captionLabel];
|
||||
|
||||
UITapGestureRecognizer *captionTap = [[UITapGestureRecognizer alloc]
|
||||
initWithTarget:self action:@selector(toggleCaption)];
|
||||
[self.captionLabel addGestureRecognizer:captionTap];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.bottomBar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[self.bottomBar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[self.bottomBar.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
|
||||
[self.captionLabel.topAnchor constraintEqualToAnchor:self.bottomBar.topAnchor constant:12],
|
||||
[self.captionLabel.leadingAnchor constraintEqualToAnchor:self.bottomBar.leadingAnchor constant:16],
|
||||
[self.captionLabel.trailingAnchor constraintEqualToAnchor:self.bottomBar.trailingAnchor constant:-16],
|
||||
[self.captionLabel.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-8],
|
||||
]];
|
||||
|
||||
// Swipe down to dismiss
|
||||
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleDismissPan:)];
|
||||
pan.delegate = (id<UIGestureRecognizerDelegate>)self;
|
||||
[self.view addGestureRecognizer:pan];
|
||||
|
||||
// Single tap toggles chrome
|
||||
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(toggleChrome)];
|
||||
tap.cancelsTouchesInView = NO;
|
||||
[self.pageVC.view addGestureRecognizer:tap];
|
||||
|
||||
[self updateChrome];
|
||||
}
|
||||
|
||||
- (void)updateChrome {
|
||||
SCIMediaViewerItem *item = self.items[self.currentIndex];
|
||||
|
||||
// Counter (hide for single items)
|
||||
if (self.items.count > 1) {
|
||||
self.counterLabel.text = [NSString stringWithFormat:@"%lu / %lu", (unsigned long)(self.currentIndex + 1), (unsigned long)self.items.count];
|
||||
self.counterLabel.hidden = NO;
|
||||
} else {
|
||||
self.counterLabel.hidden = YES;
|
||||
}
|
||||
|
||||
// Caption
|
||||
if (item.caption.length) {
|
||||
self.captionLabel.text = item.caption;
|
||||
self.bottomBar.hidden = NO;
|
||||
} else {
|
||||
self.bottomBar.hidden = YES;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)toggleChrome {
|
||||
self.chromeVisible = !self.chromeVisible;
|
||||
[UIView animateWithDuration:0.25 animations:^{
|
||||
CGFloat a = self.chromeVisible ? 1.0 : 0.0;
|
||||
self.topBar.alpha = a;
|
||||
self.bottomBar.alpha = a;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)toggleCaption {
|
||||
self.captionExpanded = !self.captionExpanded;
|
||||
[UIView animateWithDuration:0.25 animations:^{
|
||||
self.captionLabel.numberOfLines = self.captionExpanded ? 0 : 3;
|
||||
[self.view layoutIfNeeded];
|
||||
}];
|
||||
}
|
||||
|
||||
- (BOOL)gestureRecognizerShouldBegin:(UIPanGestureRecognizer *)gr {
|
||||
if (![gr isKindOfClass:[UIPanGestureRecognizer class]]) return YES;
|
||||
CGPoint v = [gr velocityInView:self.view];
|
||||
return fabs(v.y) > fabs(v.x) && v.y > 0;
|
||||
}
|
||||
|
||||
- (void)handleDismissPan:(UIPanGestureRecognizer *)gr {
|
||||
CGFloat ty = [gr translationInView:self.view].y;
|
||||
CGFloat h = self.view.bounds.size.height;
|
||||
CGFloat progress = fmin(fmax(ty / h, 0), 1);
|
||||
|
||||
switch (gr.state) {
|
||||
case UIGestureRecognizerStateChanged: {
|
||||
self.view.transform = CGAffineTransformMakeTranslation(0, ty);
|
||||
self.view.alpha = 1.0 - progress * 0.5;
|
||||
break;
|
||||
}
|
||||
case UIGestureRecognizerStateEnded:
|
||||
case UIGestureRecognizerStateCancelled: {
|
||||
CGFloat vy = [gr velocityInView:self.view].y;
|
||||
if (progress > 0.25 || vy > 800) {
|
||||
[UIView animateWithDuration:0.2 animations:^{
|
||||
self.view.transform = CGAffineTransformMakeTranslation(0, h);
|
||||
self.view.alpha = 0;
|
||||
} completion:^(BOOL finished) {
|
||||
[self dismissViewControllerAnimated:NO completion:nil];
|
||||
}];
|
||||
} else {
|
||||
[UIView animateWithDuration:0.25 delay:0 usingSpringWithDamping:0.8 initialSpringVelocity:0 options:0 animations:^{
|
||||
self.view.transform = CGAffineTransformIdentity;
|
||||
self.view.alpha = 1;
|
||||
} completion:nil];
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)closeTapped {
|
||||
// Pause any playing video
|
||||
UIViewController *current = self.pageVC.viewControllers.firstObject;
|
||||
if ([current isKindOfClass:[_SCIVideoPageVC class]]) {
|
||||
[(((_SCIVideoPageVC *)current).playerVC.player) pause];
|
||||
}
|
||||
[self dismissViewControllerAnimated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)shareTapped {
|
||||
SCIMediaViewerItem *item = self.items[self.currentIndex];
|
||||
NSMutableArray *shareItems = [NSMutableArray array];
|
||||
|
||||
UIViewController *current = self.pageVC.viewControllers.firstObject;
|
||||
if ([current isKindOfClass:[_SCIPhotoPageVC class]]) {
|
||||
UIImage *img = [(_SCIPhotoPageVC *)current currentImage];
|
||||
if (img) [shareItems addObject:img];
|
||||
}
|
||||
|
||||
// For videos or if no image loaded, share the URL
|
||||
if (!shareItems.count) {
|
||||
NSURL *url = item.videoURL ?: item.photoURL;
|
||||
if (url) [shareItems addObject:url];
|
||||
}
|
||||
|
||||
if (!shareItems.count) return;
|
||||
|
||||
UIActivityViewController *vc = [[UIActivityViewController alloc] initWithActivityItems:shareItems applicationActivities:nil];
|
||||
vc.popoverPresentationController.sourceView = self.shareBtn;
|
||||
[self presentViewController:vc animated:YES completion:nil];
|
||||
}
|
||||
|
||||
// ─── Page data source ───
|
||||
|
||||
- (UIViewController *)viewControllerForIndex:(NSUInteger)idx {
|
||||
if (idx >= self.items.count) return nil;
|
||||
SCIMediaViewerItem *item = self.items[idx];
|
||||
|
||||
if (item.videoURL) {
|
||||
_SCIVideoPageVC *vc = [[_SCIVideoPageVC alloc] init];
|
||||
vc.videoURL = item.videoURL;
|
||||
vc.view.tag = (NSInteger)idx;
|
||||
return vc;
|
||||
} else if (item.photoURL) {
|
||||
_SCIPhotoPageVC *vc = [[_SCIPhotoPageVC alloc] init];
|
||||
vc.photoURL = item.photoURL;
|
||||
vc.view.tag = (NSInteger)idx;
|
||||
return vc;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (UIViewController *)pageViewController:(UIPageViewController *)pvc viewControllerBeforeViewController:(UIViewController *)vc {
|
||||
NSInteger idx = vc.view.tag;
|
||||
if (idx <= 0) return nil;
|
||||
return [self viewControllerForIndex:idx - 1];
|
||||
}
|
||||
|
||||
- (UIViewController *)pageViewController:(UIPageViewController *)pvc viewControllerAfterViewController:(UIViewController *)vc {
|
||||
NSInteger idx = vc.view.tag;
|
||||
if (idx + 1 >= (NSInteger)self.items.count) return nil;
|
||||
return [self viewControllerForIndex:idx + 1];
|
||||
}
|
||||
|
||||
- (void)pageViewController:(UIPageViewController *)pvc didFinishAnimating:(BOOL)finished
|
||||
previousViewControllers:(NSArray<UIViewController *> *)prev transitionCompleted:(BOOL)completed {
|
||||
if (!completed) return;
|
||||
UIViewController *current = pvc.viewControllers.firstObject;
|
||||
self.currentIndex = (NSUInteger)current.view.tag;
|
||||
|
||||
// Pause previous video
|
||||
for (UIViewController *p in prev) {
|
||||
if ([p isKindOfClass:[_SCIVideoPageVC class]]) {
|
||||
[((_SCIVideoPageVC *)p).playerVC.player pause];
|
||||
}
|
||||
}
|
||||
// Play new video
|
||||
if ([current isKindOfClass:[_SCIVideoPageVC class]]) {
|
||||
[((_SCIVideoPageVC *)current).playerVC.player play];
|
||||
}
|
||||
|
||||
[self updateChrome];
|
||||
}
|
||||
|
||||
- (BOOL)prefersStatusBarHidden { return YES; }
|
||||
- (BOOL)prefersHomeIndicatorAutoHidden { return YES; }
|
||||
|
||||
@end
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Public API
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@implementation SCIMediaViewer
|
||||
|
||||
+ (void)presentNativeVideoPlayer:(NSURL *)url {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
AVPlayerViewController *playerVC = [[AVPlayerViewController alloc] init];
|
||||
playerVC.player = [AVPlayer playerWithURL:url];
|
||||
playerVC.modalPresentationStyle = UIModalPresentationFullScreen;
|
||||
[topMostController() presentViewController:playerVC animated:YES completion:^{
|
||||
[playerVC.player play];
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
+ (void)showItem:(SCIMediaViewerItem *)item {
|
||||
if (!item) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media to show")]; return; }
|
||||
|
||||
// Single video → native AVPlayerViewController directly (no wrapper)
|
||||
if (item.videoURL) {
|
||||
[self presentNativeVideoPlayer:item.videoURL];
|
||||
return;
|
||||
}
|
||||
|
||||
// Single photo → use our photo viewer container
|
||||
[self showItems:@[item] startIndex:0];
|
||||
}
|
||||
|
||||
+ (void)showItems:(NSArray<SCIMediaViewerItem *> *)items startIndex:(NSUInteger)index {
|
||||
if (!items.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media to show")]; return; }
|
||||
if (index >= items.count) index = 0;
|
||||
|
||||
// Single video item → native player
|
||||
if (items.count == 1 && items[0].videoURL) {
|
||||
[self presentNativeVideoPlayer:items[0].videoURL];
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
_SCIMediaViewerContainerVC *vc = [[_SCIMediaViewerContainerVC alloc] init];
|
||||
vc.items = items;
|
||||
vc.currentIndex = index;
|
||||
vc.modalPresentationStyle = UIModalPresentationOverFullScreen;
|
||||
vc.modalTransitionStyle = UIModalTransitionStyleCrossDissolve;
|
||||
[topMostController() presentViewController:vc animated:YES completion:nil];
|
||||
});
|
||||
}
|
||||
|
||||
+ (void)showWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption {
|
||||
[self showItem:[SCIMediaViewerItem itemWithVideoURL:videoURL photoURL:photoURL caption:caption]];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,10 @@
|
||||
// SCIRepostSheet — download media, save to Photos, open IG's creation flow.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface SCIRepostSheet : NSObject
|
||||
|
||||
/// Download media, save to Photos, open IG's creation flow.
|
||||
+ (void)repostWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,109 @@
|
||||
#import "SCIRepostSheet.h"
|
||||
#import "../Utils.h"
|
||||
#import "../Downloader/Download.h"
|
||||
#import "../PhotoAlbum.h"
|
||||
#import <Photos/Photos.h>
|
||||
|
||||
@implementation SCIRepostSheet
|
||||
|
||||
+ (void)repostWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL {
|
||||
NSURL *url = videoURL ?: photoURL;
|
||||
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media URL")]; return; }
|
||||
|
||||
// Show pill
|
||||
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
|
||||
[pill resetState];
|
||||
[pill setText:SCILocalized(@"Preparing repost...")];
|
||||
[pill setSubtitle:nil];
|
||||
UIView *hostView = [UIApplication sharedApplication].keyWindow ?: topMostController().view;
|
||||
if (hostView) [pill showInView:hostView];
|
||||
|
||||
// Download to temp file
|
||||
NSString *ext = [[url lastPathComponent] pathExtension];
|
||||
if (!ext.length) ext = videoURL ? @"mp4" : @"jpg";
|
||||
NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"repost_%@.%@", [[NSUUID UUID] UUIDString], ext]];
|
||||
|
||||
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
|
||||
downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
|
||||
if (err || !loc) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[pill showError:SCILocalized(@"Download failed")];
|
||||
[pill dismissAfterDelay:2.0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
NSError *mv = nil;
|
||||
NSURL *fileURL = [NSURL fileURLWithPath:tmp];
|
||||
[[NSFileManager defaultManager] moveItemAtURL:loc toURL:fileURL error:&mv];
|
||||
if (mv) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[pill showError:SCILocalized(@"Save failed")];
|
||||
[pill dismissAfterDelay:2.0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to Photos and get the localIdentifier
|
||||
[self saveToPhotosAndOpenCreation:fileURL isVideo:(videoURL != nil) pill:pill];
|
||||
}];
|
||||
[task resume];
|
||||
}
|
||||
|
||||
+ (void)saveToPhotosAndOpenCreation:(NSURL *)fileURL isVideo:(BOOL)isVideo pill:(SCIDownloadPillView *)pill {
|
||||
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
|
||||
if (status != PHAuthorizationStatusAuthorized) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[pill showError:SCILocalized(@"Photos access denied")];
|
||||
[pill dismissAfterDelay:2.0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
__block NSString *localId = nil;
|
||||
|
||||
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
|
||||
PHAssetCreationRequest *req;
|
||||
if (isVideo) {
|
||||
req = [PHAssetCreationRequest creationRequestForAssetFromVideoAtFileURL:fileURL];
|
||||
} else {
|
||||
UIImage *img = [UIImage imageWithContentsOfFile:fileURL.path];
|
||||
if (img) {
|
||||
req = [PHAssetCreationRequest creationRequestForAssetFromImage:img];
|
||||
} else {
|
||||
req = [PHAssetCreationRequest creationRequestForAsset];
|
||||
PHAssetResourceCreationOptions *opts = [PHAssetResourceCreationOptions new];
|
||||
opts.shouldMoveFile = YES;
|
||||
[req addResourceWithType:PHAssetResourceTypePhoto fileURL:fileURL options:opts];
|
||||
}
|
||||
}
|
||||
localId = req.placeholderForCreatedAsset.localIdentifier;
|
||||
} completionHandler:^(BOOL success, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (!success || !localId.length) {
|
||||
[pill showError:SCILocalized(@"Failed to save")];
|
||||
[pill dismissAfterDelay:2.0];
|
||||
return;
|
||||
}
|
||||
|
||||
[pill showSuccess:SCILocalized(@"Opening creator...")];
|
||||
[pill dismissAfterDelay:1.0];
|
||||
|
||||
// Open IG's native creation flow with the saved asset
|
||||
NSString *urlStr = [NSString stringWithFormat:@"instagram://library?LocalIdentifier=%@",
|
||||
[localId stringByAddingPercentEncodingWithAllowedCharacters:
|
||||
[NSCharacterSet URLQueryAllowedCharacterSet]]];
|
||||
NSURL *igURL = [NSURL URLWithString:urlStr];
|
||||
if ([[UIApplication sharedApplication] canOpenURL:igURL]) {
|
||||
[[UIApplication sharedApplication] openURL:igURL options:@{} completionHandler:nil];
|
||||
} else {
|
||||
// Fallback: show share sheet
|
||||
[SCIUtils showShareVC:fileURL];
|
||||
}
|
||||
});
|
||||
}];
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.5 MiB |
@@ -8,17 +8,34 @@
|
||||
#import "Manager.h"
|
||||
|
||||
@interface SCIDownloadPillView : UIView
|
||||
@property (nonatomic, strong) UIProgressView *progressRing;
|
||||
@property (nonatomic, strong) UIProgressView *progressBar;
|
||||
@property (nonatomic, strong) UILabel *textLabel;
|
||||
@property (nonatomic, strong) UILabel *subtitleLabel;
|
||||
@property (nonatomic, strong) UIButton *cancelButton;
|
||||
@property (nonatomic, strong) UIImageView *iconView;
|
||||
@property (nonatomic, copy) void (^onCancel)(void);
|
||||
|
||||
- (void)resetState;
|
||||
- (void)showInView:(UIView *)view;
|
||||
- (void)dismiss;
|
||||
- (void)dismissAfterDelay:(NSTimeInterval)delay;
|
||||
- (void)setProgress:(float)progress;
|
||||
- (void)setText:(NSString *)text;
|
||||
- (void)setSubtitle:(NSString *)text;
|
||||
- (void)showSuccess:(NSString *)text;
|
||||
- (void)showError:(NSString *)text;
|
||||
- (void)showBulkProgress:(NSUInteger)completed total:(NSUInteger)total;
|
||||
|
||||
// Multi-download ticket API. All methods are safe from any thread.
|
||||
// Tap-to-cancel pops the most recently pushed ticket.
|
||||
- (NSString *)beginTicketWithTitle:(NSString *)title onCancel:(void (^)(void))cancel;
|
||||
- (void)updateTicket:(NSString *)ticketId progress:(float)progress;
|
||||
- (void)updateTicket:(NSString *)ticketId text:(NSString *)text;
|
||||
- (void)finishTicket:(NSString *)ticketId successMessage:(NSString *)message;
|
||||
- (void)finishTicket:(NSString *)ticketId errorMessage:(NSString *)message;
|
||||
- (void)finishTicket:(NSString *)ticketId cancelled:(NSString *)message;
|
||||
|
||||
/// Shared singleton pill — reused across all downloads so only one shows at a time.
|
||||
+ (instancetype)shared;
|
||||
@end
|
||||
|
||||
@interface SCIDownloadDelegate : NSObject <SCIDownloadDelegateProtocol>
|
||||
@@ -33,6 +50,7 @@ typedef NS_ENUM(NSUInteger, DownloadAction) {
|
||||
|
||||
@property (nonatomic, strong) SCIDownloadManager *downloadManager;
|
||||
@property (nonatomic, strong) SCIDownloadPillView *pill;
|
||||
@property (nonatomic, copy) NSString *ticketId;
|
||||
|
||||
- (instancetype)initWithAction:(DownloadAction)action showProgress:(BOOL)showProgress;
|
||||
|
||||
|
||||
@@ -2,70 +2,145 @@
|
||||
#import "../PhotoAlbum.h"
|
||||
#import <Photos/Photos.h>
|
||||
|
||||
#pragma mark - Ticket slot
|
||||
|
||||
@interface SCIDownloadSlot : NSObject
|
||||
@property (nonatomic, copy) NSString *ticketId;
|
||||
@property (nonatomic, copy) NSString *title;
|
||||
@property (nonatomic, assign) float progress;
|
||||
@property (nonatomic, copy) void (^onCancel)(void);
|
||||
@property (nonatomic, assign) BOOL finished;
|
||||
@end
|
||||
@implementation SCIDownloadSlot @end
|
||||
|
||||
#pragma mark - SCIDownloadPillView
|
||||
|
||||
@interface SCIDownloadPillView ()
|
||||
@property (nonatomic, strong) NSMutableArray<SCIDownloadSlot *> *slots;
|
||||
@end
|
||||
|
||||
@implementation SCIDownloadPillView
|
||||
|
||||
+ (instancetype)shared {
|
||||
static SCIDownloadPillView *s;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ s = [[SCIDownloadPillView alloc] init]; });
|
||||
return s;
|
||||
}
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super initWithFrame:CGRectZero];
|
||||
if (self) {
|
||||
self.backgroundColor = [UIColor colorWithWhite:0.1 alpha:0.92];
|
||||
self.layer.cornerRadius = 20;
|
||||
_slots = [NSMutableArray array];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(_sciAppDidBecomeActive)
|
||||
name:UIApplicationDidBecomeActiveNotification object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(_sciAppDidEnterBackground)
|
||||
name:UIApplicationDidEnterBackgroundNotification object:nil];
|
||||
UIBlurEffect *blur = [UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemUltraThinMaterialDark];
|
||||
UIVisualEffectView *blurView = [[UIVisualEffectView alloc] initWithEffect:blur];
|
||||
blurView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
blurView.layer.cornerRadius = 16;
|
||||
blurView.clipsToBounds = YES;
|
||||
[self addSubview:blurView];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[blurView.topAnchor constraintEqualToAnchor:self.topAnchor],
|
||||
[blurView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
|
||||
[blurView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
|
||||
[blurView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
|
||||
]];
|
||||
|
||||
self.layer.cornerRadius = 16;
|
||||
self.clipsToBounds = YES;
|
||||
self.alpha = 0;
|
||||
|
||||
// Circular progress (using a small CAShapeLayer ring)
|
||||
_progressRing = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
|
||||
_progressRing.progressTintColor = [UIColor systemBlueColor];
|
||||
_progressRing.trackTintColor = [UIColor colorWithWhite:0.3 alpha:1.0];
|
||||
_progressRing.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_progressRing.layer.cornerRadius = 2;
|
||||
_progressRing.clipsToBounds = YES;
|
||||
[self addSubview:_progressRing];
|
||||
// Icon
|
||||
_iconView = [[UIImageView alloc] init];
|
||||
_iconView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_iconView.tintColor = [UIColor whiteColor];
|
||||
_iconView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
|
||||
_iconView.image = [UIImage systemImageNamed:@"arrow.down.circle" withConfiguration:cfg];
|
||||
[self addSubview:_iconView];
|
||||
|
||||
// Text
|
||||
_textLabel = [[UILabel alloc] init];
|
||||
_textLabel.text = @"Downloading 0%";
|
||||
_textLabel.text = SCILocalized(@"Downloading...");
|
||||
_textLabel.textColor = [UIColor whiteColor];
|
||||
_textLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
|
||||
_textLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
|
||||
_textLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_textLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self addSubview:_textLabel];
|
||||
|
||||
// Subtitle
|
||||
_subtitleLabel = [[UILabel alloc] init];
|
||||
_subtitleLabel.text = @"Tap to cancel";
|
||||
_subtitleLabel.textColor = [UIColor colorWithWhite:0.6 alpha:1.0];
|
||||
_subtitleLabel.font = [UIFont systemFontOfSize:10 weight:UIFontWeightRegular];
|
||||
_subtitleLabel.text = SCILocalized(@"Tap to cancel");
|
||||
_subtitleLabel.textColor = [UIColor colorWithWhite:0.7 alpha:1.0];
|
||||
_subtitleLabel.font = [UIFont systemFontOfSize:11 weight:UIFontWeightRegular];
|
||||
_subtitleLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self addSubview:_subtitleLabel];
|
||||
|
||||
// Tap gesture for cancel
|
||||
// Progress bar
|
||||
_progressBar = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
|
||||
_progressBar.progressTintColor = [UIColor systemBlueColor];
|
||||
_progressBar.trackTintColor = [UIColor colorWithWhite:0.3 alpha:0.5];
|
||||
_progressBar.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_progressBar.layer.cornerRadius = 1.5;
|
||||
_progressBar.clipsToBounds = YES;
|
||||
[self addSubview:_progressBar];
|
||||
|
||||
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap)];
|
||||
[self addGestureRecognizer:tap];
|
||||
|
||||
// Layout: [progress bar]
|
||||
// [text centered]
|
||||
// [subtitle centered]
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[_progressRing.topAnchor constraintEqualToAnchor:self.topAnchor constant:12],
|
||||
[_progressRing.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:16],
|
||||
[_progressRing.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16],
|
||||
[_progressRing.heightAnchor constraintEqualToConstant:4],
|
||||
[_iconView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:14],
|
||||
[_iconView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor constant:-2],
|
||||
[_iconView.widthAnchor constraintEqualToConstant:22],
|
||||
[_iconView.heightAnchor constraintEqualToConstant:22],
|
||||
|
||||
[_textLabel.topAnchor constraintEqualToAnchor:_progressRing.bottomAnchor constant:6],
|
||||
[_textLabel.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
|
||||
[_textLabel.topAnchor constraintEqualToAnchor:self.topAnchor constant:10],
|
||||
[_textLabel.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:10],
|
||||
[_textLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-14],
|
||||
|
||||
[_subtitleLabel.topAnchor constraintEqualToAnchor:_textLabel.bottomAnchor constant:2],
|
||||
[_subtitleLabel.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
|
||||
[_subtitleLabel.bottomAnchor constraintEqualToAnchor:self.bottomAnchor constant:-10],
|
||||
[_subtitleLabel.topAnchor constraintEqualToAnchor:_textLabel.bottomAnchor constant:1],
|
||||
[_subtitleLabel.leadingAnchor constraintEqualToAnchor:_textLabel.leadingAnchor],
|
||||
[_subtitleLabel.trailingAnchor constraintEqualToAnchor:_textLabel.trailingAnchor],
|
||||
|
||||
[_progressBar.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
|
||||
[_progressBar.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
|
||||
[_progressBar.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
|
||||
[_progressBar.heightAnchor constraintEqualToConstant:3],
|
||||
|
||||
[_subtitleLabel.bottomAnchor constraintEqualToAnchor:_progressBar.topAnchor constant:-8],
|
||||
]];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)handleTap {
|
||||
if (self.onCancel) self.onCancel();
|
||||
if (self.slots.count > 0) {
|
||||
SCIDownloadSlot *top = self.slots.lastObject;
|
||||
void (^cb)(void) = top.onCancel;
|
||||
top.onCancel = nil;
|
||||
if (cb) cb();
|
||||
return;
|
||||
}
|
||||
void (^cb)(void) = self.onCancel;
|
||||
self.onCancel = nil;
|
||||
if (cb) cb();
|
||||
}
|
||||
|
||||
- (void)resetState {
|
||||
self.progressBar.progress = 0;
|
||||
self.progressBar.hidden = NO;
|
||||
self.subtitleLabel.hidden = NO;
|
||||
self.subtitleLabel.text = SCILocalized(@"Tap to cancel");
|
||||
self.textLabel.text = SCILocalized(@"Downloading...");
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
|
||||
self.iconView.image = [UIImage systemImageNamed:@"arrow.down.circle" withConfiguration:cfg];
|
||||
self.iconView.tintColor = [UIColor whiteColor];
|
||||
}
|
||||
|
||||
- (void)showInView:(UIView *)view {
|
||||
@@ -74,23 +149,32 @@
|
||||
[view addSubview:self];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.topAnchor constraintEqualToAnchor:view.safeAreaLayoutGuide.topAnchor constant:4],
|
||||
[self.topAnchor constraintEqualToAnchor:view.safeAreaLayoutGuide.topAnchor constant:8],
|
||||
[self.centerXAnchor constraintEqualToAnchor:view.centerXAnchor],
|
||||
[self.widthAnchor constraintGreaterThanOrEqualToConstant:160],
|
||||
[self.widthAnchor constraintLessThanOrEqualToConstant:220],
|
||||
[self.widthAnchor constraintGreaterThanOrEqualToConstant:200],
|
||||
[self.widthAnchor constraintLessThanOrEqualToConstant:300],
|
||||
]];
|
||||
|
||||
[UIView animateWithDuration:0.25 animations:^{
|
||||
[UIView animateWithDuration:0.3 delay:0 usingSpringWithDamping:0.8 initialSpringVelocity:0.5
|
||||
options:UIViewAnimationOptionCurveEaseOut animations:^{
|
||||
self.alpha = 1;
|
||||
}];
|
||||
} completion:nil];
|
||||
}
|
||||
|
||||
- (void)dismiss {
|
||||
[UIView animateWithDuration:0.2 animations:^{
|
||||
self.alpha = 0;
|
||||
} completion:^(BOOL finished) {
|
||||
[self removeFromSuperview];
|
||||
}];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
// A new ticket raced in — keep the pill alive.
|
||||
if (self.slots.count > 0) return;
|
||||
if (self.alpha <= 0.01 && !self.superview) return;
|
||||
self.onCancel = nil;
|
||||
[UIView animateWithDuration:0.25 animations:^{
|
||||
self.alpha = 0;
|
||||
self.transform = CGAffineTransformMakeScale(0.9, 0.9);
|
||||
} completion:^(BOOL finished) {
|
||||
[self removeFromSuperview];
|
||||
self.transform = CGAffineTransformIdentity;
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)dismissAfterDelay:(NSTimeInterval)delay {
|
||||
@@ -100,13 +184,203 @@
|
||||
}
|
||||
|
||||
- (void)setProgress:(float)progress {
|
||||
[self.progressRing setProgress:progress animated:YES];
|
||||
self.progressBar.hidden = NO;
|
||||
[self.progressBar setProgress:progress animated:YES];
|
||||
}
|
||||
|
||||
- (void)setText:(NSString *)text {
|
||||
self.textLabel.text = text;
|
||||
}
|
||||
|
||||
- (void)setSubtitle:(NSString *)text {
|
||||
self.subtitleLabel.text = text;
|
||||
self.subtitleLabel.hidden = (text.length == 0);
|
||||
}
|
||||
|
||||
- (void)showSuccess:(NSString *)text {
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
|
||||
self.iconView.image = [UIImage systemImageNamed:@"checkmark.circle.fill" withConfiguration:cfg];
|
||||
self.iconView.tintColor = [UIColor systemGreenColor];
|
||||
self.textLabel.text = text;
|
||||
self.subtitleLabel.hidden = YES;
|
||||
self.progressBar.hidden = YES;
|
||||
self.onCancel = nil;
|
||||
}
|
||||
|
||||
- (void)showError:(NSString *)text {
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
|
||||
self.iconView.image = [UIImage systemImageNamed:@"xmark.circle.fill" withConfiguration:cfg];
|
||||
self.iconView.tintColor = [UIColor systemRedColor];
|
||||
self.textLabel.text = text;
|
||||
self.subtitleLabel.hidden = YES;
|
||||
self.progressBar.hidden = YES;
|
||||
self.onCancel = nil;
|
||||
}
|
||||
|
||||
- (void)showBulkProgress:(NSUInteger)completed total:(NSUInteger)total {
|
||||
self.textLabel.text = [NSString stringWithFormat:@"Downloading %lu of %lu", (unsigned long)completed + 1, (unsigned long)total];
|
||||
self.subtitleLabel.text = SCILocalized(@"Tap to cancel");
|
||||
self.subtitleLabel.hidden = NO;
|
||||
self.progressBar.hidden = NO;
|
||||
[self.progressBar setProgress:(float)completed / (float)total animated:YES];
|
||||
}
|
||||
|
||||
#pragma mark - Ticket API
|
||||
|
||||
- (void)_onMain:(dispatch_block_t)block {
|
||||
if ([NSThread isMainThread]) block();
|
||||
else dispatch_async(dispatch_get_main_queue(), block);
|
||||
}
|
||||
|
||||
- (SCIDownloadSlot *)_slotForId:(NSString *)ticketId {
|
||||
if (!ticketId) return nil;
|
||||
for (SCIDownloadSlot *s in self.slots) {
|
||||
if ([s.ticketId isEqualToString:ticketId]) return s;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (void)_renderTop {
|
||||
SCIDownloadSlot *top = self.slots.lastObject;
|
||||
if (!top) return;
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
|
||||
self.iconView.image = [UIImage systemImageNamed:@"arrow.down.circle" withConfiguration:cfg];
|
||||
self.iconView.tintColor = [UIColor whiteColor];
|
||||
self.textLabel.text = top.title ?: @"Downloading...";
|
||||
self.progressBar.hidden = NO;
|
||||
[self.progressBar setProgress:top.progress animated:YES];
|
||||
self.subtitleLabel.hidden = NO;
|
||||
if (self.slots.count > 1) {
|
||||
self.subtitleLabel.text = [NSString stringWithFormat:@"%lu active — tap to cancel",
|
||||
(unsigned long)self.slots.count];
|
||||
} else {
|
||||
self.subtitleLabel.text = SCILocalized(@"Tap to cancel");
|
||||
}
|
||||
}
|
||||
|
||||
- (NSString *)beginTicketWithTitle:(NSString *)title onCancel:(void (^)(void))cancel {
|
||||
NSString *ticketId = [[NSUUID UUID] UUIDString];
|
||||
void (^cancelCopy)(void) = [cancel copy];
|
||||
[self _onMain:^{
|
||||
SCIDownloadSlot *slot = [SCIDownloadSlot new];
|
||||
slot.ticketId = ticketId;
|
||||
slot.title = title ?: @"Downloading...";
|
||||
slot.progress = 0;
|
||||
slot.onCancel = cancelCopy;
|
||||
[self.slots addObject:slot];
|
||||
|
||||
// Reset visual state so the prior download's final frame doesn't leak in.
|
||||
[self.progressBar setProgress:0 animated:NO];
|
||||
self.alpha = 1;
|
||||
self.transform = CGAffineTransformIdentity;
|
||||
if (!self.superview) {
|
||||
UIView *host = [UIApplication sharedApplication].keyWindow ?: topMostController().view;
|
||||
if (host) [self showInView:host];
|
||||
}
|
||||
[self _renderTop];
|
||||
}];
|
||||
return ticketId;
|
||||
}
|
||||
|
||||
- (void)_sciAppDidBecomeActive {
|
||||
[self _onMain:^{
|
||||
if (self.slots.count == 0 && (self.superview || self.alpha > 0.01)) {
|
||||
self.alpha = 0;
|
||||
self.transform = CGAffineTransformIdentity;
|
||||
[self removeFromSuperview];
|
||||
} else if (self.slots.count > 0) {
|
||||
[self _renderTop];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// iOS suspends networking + ffmpeg on background — cancel active tickets so the
|
||||
// pill clears cleanly on return. User re-initiates the download.
|
||||
- (void)_sciAppDidEnterBackground {
|
||||
[self _onMain:^{
|
||||
for (SCIDownloadSlot *slot in [self.slots copy]) {
|
||||
void (^cb)(void) = slot.onCancel;
|
||||
slot.onCancel = nil;
|
||||
if (cb) cb();
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
- (void)updateTicket:(NSString *)ticketId progress:(float)progress {
|
||||
[self _onMain:^{
|
||||
SCIDownloadSlot *s = [self _slotForId:ticketId];
|
||||
if (!s || s.finished) return;
|
||||
s.progress = progress;
|
||||
if (self.slots.lastObject == s) [self.progressBar setProgress:progress animated:YES];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)updateTicket:(NSString *)ticketId text:(NSString *)text {
|
||||
[self _onMain:^{
|
||||
SCIDownloadSlot *s = [self _slotForId:ticketId];
|
||||
if (!s || s.finished) return;
|
||||
s.title = text ?: s.title;
|
||||
if (self.slots.lastObject == s) self.textLabel.text = s.title;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)_removeSlot:(SCIDownloadSlot *)slot
|
||||
finalText:(NSString *)finalText
|
||||
finalIcon:(NSString *)finalIcon
|
||||
iconColor:(UIColor *)iconColor {
|
||||
if (!slot || slot.finished) return;
|
||||
slot.finished = YES;
|
||||
slot.onCancel = nil;
|
||||
[self.slots removeObject:slot];
|
||||
|
||||
if (self.slots.count > 0) {
|
||||
[self _renderTop];
|
||||
return;
|
||||
}
|
||||
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
|
||||
self.iconView.image = [UIImage systemImageNamed:finalIcon withConfiguration:cfg];
|
||||
self.iconView.tintColor = iconColor;
|
||||
self.textLabel.text = finalText;
|
||||
self.subtitleLabel.hidden = YES;
|
||||
self.progressBar.hidden = YES;
|
||||
[self dismissAfterDelay:1.2];
|
||||
}
|
||||
|
||||
- (void)finishTicket:(NSString *)ticketId successMessage:(NSString *)message {
|
||||
[self _onMain:^{
|
||||
SCIDownloadSlot *s = [self _slotForId:ticketId];
|
||||
[self _removeSlot:s
|
||||
finalText:message ?: @"Done"
|
||||
finalIcon:@"checkmark.circle.fill"
|
||||
iconColor:[UIColor systemGreenColor]];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)finishTicket:(NSString *)ticketId errorMessage:(NSString *)message {
|
||||
[self _onMain:^{
|
||||
SCIDownloadSlot *s = [self _slotForId:ticketId];
|
||||
[self _removeSlot:s
|
||||
finalText:message ?: @"Failed"
|
||||
finalIcon:@"xmark.circle.fill"
|
||||
iconColor:[UIColor systemRedColor]];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)finishTicket:(NSString *)ticketId cancelled:(NSString *)message {
|
||||
[self _onMain:^{
|
||||
SCIDownloadSlot *s = [self _slotForId:ticketId];
|
||||
[self _removeSlot:s
|
||||
finalText:message ?: @"Cancelled"
|
||||
finalIcon:@"xmark.circle.fill"
|
||||
iconColor:[UIColor systemOrangeColor]];
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
@@ -127,33 +401,13 @@
|
||||
}
|
||||
|
||||
- (void)downloadFileWithURL:(NSURL *)url fileExtension:(NSString *)fileExtension hudLabel:(NSString *)hudLabel {
|
||||
// Dismiss any existing pill
|
||||
[self.pill dismiss];
|
||||
|
||||
self.pill = [[SCIDownloadPillView alloc] init];
|
||||
|
||||
if (hudLabel) {
|
||||
[self.pill setText:hudLabel];
|
||||
}
|
||||
|
||||
if (!self.showProgress) {
|
||||
self.pill.progressRing.hidden = YES;
|
||||
self.pill.subtitleLabel.text = nil;
|
||||
}
|
||||
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
|
||||
self.pill = pill;
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
self.pill.onCancel = ^{
|
||||
self.ticketId = [pill beginTicketWithTitle:hudLabel ?: @"Downloading..." onCancel:^{
|
||||
[weakSelf.downloadManager cancelDownload];
|
||||
};
|
||||
|
||||
// Show on keyWindow so it survives VC transitions (e.g. leaving stories)
|
||||
UIView *hostView = [UIApplication sharedApplication].keyWindow;
|
||||
if (!hostView) hostView = topMostController().view;
|
||||
if (!hostView) {
|
||||
NSLog(@"[SCInsta] Download: No valid view");
|
||||
return;
|
||||
}
|
||||
[self.pill showInView:hostView];
|
||||
}];
|
||||
|
||||
NSLog(@"[SCInsta] Download: Will start download for url \"%@\" with file extension: \".%@\"", url, fileExtension);
|
||||
[self.downloadManager downloadFileWithURL:url fileExtension:fileExtension];
|
||||
@@ -164,46 +418,30 @@
|
||||
}
|
||||
|
||||
- (void)downloadDidCancel {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.pill setText:@"Cancelled"];
|
||||
self.pill.subtitleLabel.text = nil;
|
||||
self.pill.progressRing.hidden = YES;
|
||||
[self.pill dismissAfterDelay:0.8];
|
||||
});
|
||||
[self.pill finishTicket:self.ticketId cancelled:@"Cancelled"];
|
||||
NSLog(@"[SCInsta] Download: Download cancelled");
|
||||
}
|
||||
|
||||
- (void)downloadDidProgress:(float)progress {
|
||||
if (self.showProgress) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.pill setProgress:progress];
|
||||
[self.pill setText:[NSString stringWithFormat:@"Downloading %d%%", (int)(progress * 100)]];
|
||||
});
|
||||
}
|
||||
if (!self.showProgress) return;
|
||||
[self.pill updateTicket:self.ticketId progress:progress];
|
||||
[self.pill updateTicket:self.ticketId text:[NSString stringWithFormat:@"Downloading %d%%", (int)(progress * 100)]];
|
||||
}
|
||||
|
||||
- (void)downloadDidFinishWithError:(NSError *)error {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (error && error.code != NSURLErrorCancelled) {
|
||||
NSLog(@"[SCInsta] Download: Download failed with error: \"%@\"", error);
|
||||
[self.pill setText:@"Download failed"];
|
||||
self.pill.subtitleLabel.text = error.localizedDescription;
|
||||
self.pill.progressRing.hidden = YES;
|
||||
[self.pill dismissAfterDelay:3.0];
|
||||
} else if (!error) {
|
||||
// nil error without fileURL callback — dismiss stale pill
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
if (self.pill.superview) [self.pill dismissAfterDelay:0];
|
||||
});
|
||||
}
|
||||
});
|
||||
if (error && error.code != NSURLErrorCancelled) {
|
||||
NSLog(@"[SCInsta] Download: Download failed with error: \"%@\"", error);
|
||||
[self.pill finishTicket:self.ticketId errorMessage:SCILocalized(@"Download failed")];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)downloadDidFinishWithFileURL:(NSURL *)fileURL {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.pill dismiss];
|
||||
|
||||
NSLog(@"[SCInsta] Download: Finished with url: \"%@\"", [fileURL absoluteString]);
|
||||
// saveToPhotos finishes the ticket after the PH completion fires.
|
||||
if (self.action != saveToPhotos) {
|
||||
[self.pill finishTicket:self.ticketId successMessage:SCILocalized(@"Done")];
|
||||
}
|
||||
|
||||
switch (self.action) {
|
||||
case share:
|
||||
@@ -218,7 +456,7 @@
|
||||
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
|
||||
if (status != PHAuthorizationStatusAuthorized) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[SCIUtils showErrorHUDWithDescription:@"Photo library access denied"];
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Photo library access denied")];
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -227,17 +465,10 @@
|
||||
void (^onDone)(BOOL, NSError *) = ^(BOOL success, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (success) {
|
||||
SCIDownloadPillView *donePill = [[SCIDownloadPillView alloc] init];
|
||||
donePill.progressRing.hidden = YES;
|
||||
donePill.subtitleLabel.text = nil;
|
||||
[donePill setText:useAlbum ? @"Saved to RyukGram" : @"Saved to Photos"];
|
||||
UIView *hostView = topMostController().view;
|
||||
if (hostView) {
|
||||
[donePill showInView:hostView];
|
||||
[donePill dismissAfterDelay:1.5];
|
||||
}
|
||||
[self.pill finishTicket:self.ticketId
|
||||
successMessage:useAlbum ? SCILocalized(@"Saved to RyukGram") : SCILocalized(@"Saved to Photos")];
|
||||
} else {
|
||||
[SCIUtils showErrorHUDWithDescription:@"Failed to save to Photos"];
|
||||
[self.pill finishTicket:self.ticketId errorMessage:SCILocalized(@"Failed to save")];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#import "Manager.h"
|
||||
#import "../ActionButton/SCIMediaActions.h"
|
||||
|
||||
@implementation SCIDownloadManager
|
||||
|
||||
@@ -31,8 +32,6 @@
|
||||
|
||||
// URLSession methods
|
||||
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
|
||||
NSLog(@"Task wrote %lld bytes of %lld bytes", bytesWritten, totalBytesExpectedToWrite);
|
||||
|
||||
float progress = (float)totalBytesWritten / (float)totalBytesExpectedToWrite;
|
||||
|
||||
[self.delegate downloadDidProgress:progress];
|
||||
@@ -46,8 +45,7 @@
|
||||
}
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
|
||||
NSLog(@"Task completed with error: %@", error);
|
||||
|
||||
if (error) NSLog(@"[SCInsta] Download error: %@", error);
|
||||
[self.delegate downloadDidFinishWithError:error];
|
||||
}
|
||||
|
||||
@@ -56,7 +54,8 @@
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
|
||||
NSString *cacheDirectoryPath = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
|
||||
NSURL *newPath = [[NSURL fileURLWithPath:cacheDirectoryPath] URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", NSUUID.UUID.UUIDString, self.fileExtension]];
|
||||
NSString *stem = [SCIMediaActions currentFilenameStem] ?: NSUUID.UUID.UUIDString;
|
||||
NSURL *newPath = [[NSURL fileURLWithPath:cacheDirectoryPath] URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", stem, self.fileExtension]];
|
||||
|
||||
NSLog(@"[SCInsta] Download Handler: Moving file from: %@ to: %@", oldPath.absoluteString, newPath.absoluteString);
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// Feed action button — hooks IGUFIInteractionCountsView.
|
||||
// Media lives on sibling cells (IGFeedItemPhotoCell, IGModernFeedVideoCell)
|
||||
// in the same collection view section, NOT on the UFI cell itself.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../SCIChrome.h"
|
||||
#import "../../ActionButton/SCIActionButton.h"
|
||||
#import "../../ActionButton/SCIMediaActions.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
static const NSInteger kFeedActionBtnTag = 13370;
|
||||
static const void *kFeedPageIndexKey = &kFeedPageIndexKey;
|
||||
|
||||
// Read _currentMediaPK from IGFeedItemUFICell.
|
||||
static NSString *sciFeedCurrentMediaPK(UIView *button) {
|
||||
UIResponder *r = button;
|
||||
Class ufiCls = NSClassFromString(@"IGFeedItemUFICell");
|
||||
while (r && !(ufiCls && [r isKindOfClass:ufiCls])) r = [r nextResponder];
|
||||
if (!r) return nil;
|
||||
Ivar iv = class_getInstanceVariable(object_getClass(r), "_currentMediaPK");
|
||||
if (!iv) return nil;
|
||||
id val = object_getIvar(r, iv);
|
||||
return [val isKindOfClass:[NSString class]] ? val : nil;
|
||||
}
|
||||
|
||||
// Current carousel page index. Returns -1 if not found.
|
||||
static NSInteger sciFeedCarouselPageIndex(UIView *button) {
|
||||
// Walk up to collection view
|
||||
UIView *v = button;
|
||||
UICollectionViewCell *ufiCell = nil;
|
||||
UICollectionView *cv = nil;
|
||||
while (v) {
|
||||
if (!ufiCell && [v isKindOfClass:[UICollectionViewCell class]]
|
||||
&& [NSStringFromClass([v class]) containsString:@"UFI"]) {
|
||||
ufiCell = (UICollectionViewCell *)v;
|
||||
}
|
||||
if ([v isKindOfClass:[UICollectionView class]]) {
|
||||
cv = (UICollectionView *)v;
|
||||
break;
|
||||
}
|
||||
v = v.superview;
|
||||
}
|
||||
if (!ufiCell || !cv) return -1;
|
||||
|
||||
NSIndexPath *ufiPath = [cv indexPathForCell:ufiCell];
|
||||
if (!ufiPath) return -1;
|
||||
NSInteger section = ufiPath.section;
|
||||
|
||||
// Find IGFeedItemPageCell in same section
|
||||
for (UICollectionViewCell *cell in cv.visibleCells) {
|
||||
NSIndexPath *path = [cv indexPathForCell:cell];
|
||||
if (!path || path.section != section) continue;
|
||||
NSString *cls = NSStringFromClass([cell class]);
|
||||
if (![cls containsString:@"Page"]) continue;
|
||||
|
||||
// BFS for IGPageMediaView
|
||||
Class pmvCls = NSClassFromString(@"IGPageMediaView");
|
||||
if (pmvCls) {
|
||||
NSMutableArray *queue = [NSMutableArray arrayWithObject:cell];
|
||||
int scanned = 0;
|
||||
UIView *pmv = nil;
|
||||
while (queue.count && scanned < 50) {
|
||||
UIView *cur = queue.firstObject; [queue removeObjectAtIndex:0]; scanned++;
|
||||
if ([cur isKindOfClass:pmvCls]) { pmv = cur; break; }
|
||||
for (UIView *s in cur.subviews) [queue addObject:s];
|
||||
}
|
||||
if (pmv && [pmv respondsToSelector:@selector(currentMediaItem)] && [pmv respondsToSelector:@selector(items)]) {
|
||||
@try {
|
||||
id current = ((id(*)(id,SEL))objc_msgSend)(pmv, @selector(currentMediaItem));
|
||||
NSArray *items = ((id(*)(id,SEL))objc_msgSend)(pmv, @selector(items));
|
||||
if (current && items.count) {
|
||||
NSUInteger idx = [items indexOfObjectIdenticalTo:current];
|
||||
if (idx != NSNotFound) return (NSInteger)idx;
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: _currentIndex ivar on the page cell
|
||||
Ivar idxIvar = class_getInstanceVariable([cell class], "_currentIndex");
|
||||
if (!idxIvar) idxIvar = class_getInstanceVariable([cell class], "_currentPage");
|
||||
if (!idxIvar) idxIvar = class_getInstanceVariable([cell class], "_currentMediaIndex");
|
||||
if (idxIvar) {
|
||||
ptrdiff_t offset = ivar_getOffset(idxIvar);
|
||||
NSInteger idx = *(NSInteger *)((char *)(__bridge void *)cell + offset);
|
||||
return idx;
|
||||
}
|
||||
|
||||
// Fallback: compute page from scroll view content offset
|
||||
{
|
||||
NSMutableArray *sq = [NSMutableArray arrayWithObject:cell];
|
||||
int sc = 0;
|
||||
while (sq.count && sc < 100) {
|
||||
UIView *cur = sq.firstObject; [sq removeObjectAtIndex:0]; sc++;
|
||||
if ([cur isKindOfClass:[UIScrollView class]] && cur != cv) {
|
||||
UIScrollView *sv = (UIScrollView *)cur;
|
||||
CGFloat pageW = sv.bounds.size.width;
|
||||
// Horizontal paging scroll view
|
||||
if (pageW > 100 && sv.contentSize.width > pageW * 1.5) {
|
||||
NSInteger idx = (NSInteger)round(sv.contentOffset.x / pageW);
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
for (UIView *s in cur.subviews) [sq addObject:s];
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Resolve current carousel child using page index.
|
||||
static id sciFeedResolveCarouselChild(id parentMedia, UIView *button) {
|
||||
if (!parentMedia) return nil;
|
||||
if (![SCIMediaActions isCarouselMedia:parentMedia]) return parentMedia;
|
||||
|
||||
NSInteger idx = sciFeedCarouselPageIndex(button);
|
||||
NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia];
|
||||
if (idx >= 0 && (NSUInteger)idx < children.count) {
|
||||
return children[idx];
|
||||
}
|
||||
return parentMedia;
|
||||
}
|
||||
|
||||
// Extract IGMedia from sibling cells in the same collection view section.
|
||||
static IGMedia *sciFeedMediaFromButton(UIView *button) {
|
||||
if (!button) return nil;
|
||||
Class mediaClass = NSClassFromString(@"IGMedia");
|
||||
if (!mediaClass) return nil;
|
||||
|
||||
// Walk up to find UFI cell and collection view
|
||||
UIView *v = button;
|
||||
UICollectionViewCell *ufiCell = nil;
|
||||
UICollectionView *cv = nil;
|
||||
|
||||
while (v) {
|
||||
if (!ufiCell && [v isKindOfClass:[UICollectionViewCell class]]
|
||||
&& [NSStringFromClass([v class]) containsString:@"UFI"]) {
|
||||
ufiCell = (UICollectionViewCell *)v;
|
||||
}
|
||||
if ([v isKindOfClass:[UICollectionView class]]) {
|
||||
cv = (UICollectionView *)v;
|
||||
break;
|
||||
}
|
||||
v = v.superview;
|
||||
}
|
||||
|
||||
if (!ufiCell || !cv) return nil;
|
||||
|
||||
// Get section
|
||||
NSIndexPath *ufiPath = [cv indexPathForCell:ufiCell];
|
||||
if (!ufiPath) return nil;
|
||||
NSInteger section = ufiPath.section;
|
||||
|
||||
// Search sibling cells for IGMedia
|
||||
for (UICollectionViewCell *cell in cv.visibleCells) {
|
||||
NSIndexPath *path = [cv indexPathForCell:cell];
|
||||
if (!path || path.section != section) continue;
|
||||
if (cell == ufiCell) continue;
|
||||
|
||||
// Filter to media cell classes
|
||||
NSString *cls = NSStringFromClass([cell class]);
|
||||
if (![cls containsString:@"Photo"] && ![cls containsString:@"Video"]
|
||||
&& ![cls containsString:@"Media"] && ![cls containsString:@"Page"]) continue;
|
||||
|
||||
// Scan ivars for IGMedia
|
||||
unsigned int count = 0;
|
||||
Class c = object_getClass(cell);
|
||||
while (c && c != [UICollectionViewCell class]) {
|
||||
Ivar *ivars = class_copyIvarList(c, &count);
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
const char *type = ivar_getTypeEncoding(ivars[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
@try {
|
||||
id val = object_getIvar(cell, ivars[i]);
|
||||
if (val && [val isKindOfClass:mediaClass]) {
|
||||
free(ivars);
|
||||
return (IGMedia *)val;
|
||||
}
|
||||
// Try .media selector on wrapper objects
|
||||
if (val && [val respondsToSelector:@selector(media)]) {
|
||||
id m = ((id(*)(id,SEL))objc_msgSend)(val, @selector(media));
|
||||
if (m && [m isKindOfClass:mediaClass]) {
|
||||
free(ivars);
|
||||
return (IGMedia *)m;
|
||||
}
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
c = class_getSuperclass(c);
|
||||
}
|
||||
|
||||
// Try mediaCellFeedItem (video cells)
|
||||
if ([cell respondsToSelector:@selector(mediaCellFeedItem)]) {
|
||||
@try {
|
||||
id m = ((id(*)(id,SEL))objc_msgSend)(cell, @selector(mediaCellFeedItem));
|
||||
if (m && [m isKindOfClass:mediaClass]) {
|
||||
return (IGMedia *)m;
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
%hook IGUFIInteractionCountsView
|
||||
|
||||
- (void)updateUFIWithButtonsConfig:(id)config interactionCountProvider:(id)provider {
|
||||
%orig;
|
||||
|
||||
if (![SCIUtils getBoolPref:@"feed_action_button"]) return;
|
||||
|
||||
SCIChromeButton *btn = (SCIChromeButton *)[self viewWithTag:kFeedActionBtnTag];
|
||||
if (![btn isKindOfClass:[SCIChromeButton class]]) btn = nil;
|
||||
if (!btn) {
|
||||
btn = [[SCIChromeButton alloc] initWithSymbol:@"ellipsis.circle" pointSize:21 diameter:36];
|
||||
btn.tag = kFeedActionBtnTag;
|
||||
btn.iconTint = [UIColor labelColor];
|
||||
btn.bubbleColor = [UIColor clearColor];
|
||||
[self addSubview:btn];
|
||||
|
||||
// Position: right side, left of bookmark. Shifted up 4pt to
|
||||
// align with the native like/comment/share icons.
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-44],
|
||||
[btn.centerYAnchor constraintEqualToAnchor:self.centerYAnchor constant:-6],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36],
|
||||
]];
|
||||
}
|
||||
|
||||
// Reconfigure with fresh media provider.
|
||||
[SCIActionButton configureButton:btn
|
||||
context:SCIActionContextFeed
|
||||
prefKey:@"feed_action_default"
|
||||
mediaProvider:^id (UIView *sourceView) {
|
||||
id parentMedia = sciFeedMediaFromButton(sourceView);
|
||||
if (!parentMedia) return nil;
|
||||
|
||||
if ([SCIMediaActions isCarouselMedia:parentMedia]) {
|
||||
NSInteger idx = sciFeedCarouselPageIndex(sourceView);
|
||||
NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia];
|
||||
if (idx >= 0 && (NSUInteger)idx < children.count) {
|
||||
// Stash page index for the menu builder to find the parent.
|
||||
objc_setAssociatedObject(sourceView, kFeedPageIndexKey,
|
||||
@(idx), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
return children[idx];
|
||||
}
|
||||
}
|
||||
return parentMedia;
|
||||
}];
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -0,0 +1,177 @@
|
||||
// Reels action button — injects a RyukGram action button above the reel's
|
||||
// vertical like/comment/share sidebar (IGSundialViewerVerticalUFI).
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../SCIChrome.h"
|
||||
#import "../../ActionButton/SCIActionButton.h"
|
||||
#import "../../ActionButton/SCIMediaActions.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
static const NSInteger kReelActionBtnTag = 1337;
|
||||
|
||||
static UIView *sciFindSuperviewOfClass(UIView *view, NSString *className) {
|
||||
Class cls = NSClassFromString(className);
|
||||
if (!cls) return nil;
|
||||
UIView *current = view.superview;
|
||||
for (int depth = 0; current && depth < 20; depth++) {
|
||||
if ([current isKindOfClass:cls]) return current;
|
||||
current = current.superview;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static id sciFindMediaIvar(UIView *view) {
|
||||
if (!view) return nil;
|
||||
Class mediaClass = NSClassFromString(@"IGMedia");
|
||||
if (!mediaClass) return nil;
|
||||
unsigned int count = 0;
|
||||
Ivar *ivars = class_copyIvarList([view class], &count);
|
||||
id found = nil;
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
const char *type = ivar_getTypeEncoding(ivars[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
@try {
|
||||
id val = object_getIvar(view, ivars[i]);
|
||||
if (val && [val isKindOfClass:mediaClass]) { found = val; break; }
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
return found;
|
||||
}
|
||||
|
||||
// Resolve the current carousel child from _currentIndex.
|
||||
static id sciCurrentCarouselChildMedia(UIView *carouselCell, id parentMedia) {
|
||||
if (!carouselCell || !parentMedia) return parentMedia;
|
||||
|
||||
// Try _currentIndex ivar
|
||||
Ivar idxIvar = class_getInstanceVariable([carouselCell class], "_currentIndex");
|
||||
NSInteger currentIdx = 0;
|
||||
if (idxIvar) {
|
||||
ptrdiff_t offset = ivar_getOffset(idxIvar);
|
||||
currentIdx = *(NSInteger *)((char *)(__bridge void *)carouselCell + offset);
|
||||
}
|
||||
|
||||
// Fallback: _currentFractionalIndex
|
||||
if (!idxIvar || currentIdx == 0) {
|
||||
Ivar fracIvar = class_getInstanceVariable([carouselCell class], "_currentFractionalIndex");
|
||||
if (fracIvar) {
|
||||
ptrdiff_t fOffset = ivar_getOffset(fracIvar);
|
||||
double fracIdx = *(double *)((char *)(__bridge void *)carouselCell + fOffset);
|
||||
NSInteger roundedIdx = (NSInteger)round(fracIdx);
|
||||
if (roundedIdx > 0) currentIdx = roundedIdx;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: inner collection view content offset
|
||||
Ivar cvIvar = class_getInstanceVariable([carouselCell class], "_collectionView");
|
||||
if (cvIvar) {
|
||||
UICollectionView *cv = object_getIvar(carouselCell, cvIvar);
|
||||
if (cv) {
|
||||
CGFloat pageWidth = cv.bounds.size.width;
|
||||
if (pageWidth > 0) {
|
||||
NSInteger cvIdx = (NSInteger)round(cv.contentOffset.x / pageWidth);
|
||||
if (cvIdx > currentIdx) currentIdx = cvIdx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia];
|
||||
if (currentIdx >= 0 && (NSUInteger)currentIdx < children.count) {
|
||||
return children[currentIdx];
|
||||
}
|
||||
return parentMedia;
|
||||
}
|
||||
|
||||
// Media provider for reels. Returns current page's child for carousels.
|
||||
static id sciReelsMediaProvider(UIView *sourceView) {
|
||||
// Video reel
|
||||
UIView *videoCell = sciFindSuperviewOfClass(sourceView, @"IGSundialViewerVideoCell");
|
||||
if (videoCell) {
|
||||
id m = sciFindMediaIvar(videoCell);
|
||||
if (m) return m;
|
||||
}
|
||||
|
||||
// Photo reel
|
||||
UIView *photoCell = sciFindSuperviewOfClass(sourceView, @"IGSundialViewerPhotoCell");
|
||||
if (photoCell) {
|
||||
id m = sciFindMediaIvar(photoCell);
|
||||
if (m) return m;
|
||||
}
|
||||
|
||||
// Carousel reel
|
||||
UIView *carouselCell = sciFindSuperviewOfClass(sourceView, @"IGSundialViewerCarouselCell");
|
||||
if (carouselCell) {
|
||||
id parentMedia = sciFindMediaIvar(carouselCell);
|
||||
if (parentMedia) {
|
||||
return sciCurrentCarouselChildMedia(carouselCell, parentMedia);
|
||||
}
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
%hook IGSundialViewerVerticalUFI
|
||||
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
|
||||
if (![SCIUtils getBoolPref:@"reels_action_button"]) return;
|
||||
if (!self.superview) return;
|
||||
|
||||
SCIChromeButton *btn = (SCIChromeButton *)[self viewWithTag:kReelActionBtnTag];
|
||||
if (![btn isKindOfClass:[SCIChromeButton class]]) btn = nil;
|
||||
|
||||
if (!btn) {
|
||||
UIImageSymbolConfiguration *symCfg =
|
||||
[UIImageSymbolConfiguration configurationWithPointSize:24 weight:UIImageSymbolWeightSemibold];
|
||||
UIImage *base = [UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:symCfg];
|
||||
// Bake the drop shadow into the image so no CALayer shadow is needed.
|
||||
CGFloat pad = 8;
|
||||
CGSize sz = CGSizeMake(base.size.width + pad * 2, base.size.height + pad * 2);
|
||||
UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:sz];
|
||||
UIImage *icon = [r imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
|
||||
CGContextRef c = ctx.CGContext;
|
||||
CGContextSaveGState(c);
|
||||
CGContextSetShadowWithColor(c, CGSizeMake(0, 1), 3,
|
||||
[UIColor colorWithWhite:0 alpha:0.55].CGColor);
|
||||
UIImage *tinted = [base imageWithTintColor:[UIColor whiteColor]
|
||||
renderingMode:UIImageRenderingModeAlwaysOriginal];
|
||||
[tinted drawInRect:CGRectMake(pad, pad, base.size.width, base.size.height)];
|
||||
CGContextRestoreGState(c);
|
||||
}];
|
||||
|
||||
btn = [[SCIChromeButton alloc] initWithSymbol:@"" pointSize:0 diameter:40];
|
||||
btn.tag = kReelActionBtnTag;
|
||||
btn.bubbleColor = [UIColor clearColor];
|
||||
btn.iconView.image = icon;
|
||||
|
||||
// Capsule configuration gives us the native dark platter animation
|
||||
// when the menu opens/closes — behaviour parity with IG's own chrome.
|
||||
UIButtonConfiguration *cfg = [UIButtonConfiguration plainButtonConfiguration];
|
||||
cfg.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
|
||||
cfg.background.backgroundColor = [UIColor clearColor];
|
||||
cfg.contentInsets = NSDirectionalEdgeInsetsZero;
|
||||
btn.configuration = cfg;
|
||||
|
||||
self.clipsToBounds = NO;
|
||||
[self addSubview:btn];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
|
||||
[btn.bottomAnchor constraintEqualToAnchor:self.topAnchor constant:-10],
|
||||
[btn.widthAnchor constraintEqualToConstant:40],
|
||||
[btn.heightAnchor constraintEqualToConstant:40]
|
||||
]];
|
||||
}
|
||||
|
||||
[SCIActionButton configureButton:btn
|
||||
context:SCIActionContextReels
|
||||
prefKey:@"reels_action_default"
|
||||
mediaProvider:^id (UIView *sourceView) {
|
||||
return sciReelsMediaProvider(sourceView);
|
||||
}];
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -1,22 +1,34 @@
|
||||
#import "../../Utils.h"
|
||||
|
||||
%hook IGDirectThreadCallButtonsCoordinator
|
||||
// Voice Call
|
||||
- (void)_didTapAudioButton:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"call_confirm"]) {
|
||||
NSLog(@"[SCInsta] Call confirm triggered");
|
||||
|
||||
// 426+ dropped the sender arg
|
||||
- (void)_didTapAudioButton {
|
||||
if ([SCIUtils getBoolPref:@"voice_call_confirm"]) {
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)_didTapVideoButton {
|
||||
if ([SCIUtils getBoolPref:@"video_call_confirm"]) {
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-426 signatures
|
||||
- (void)_didTapAudioButton:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"voice_call_confirm"]) {
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
|
||||
// Video Call
|
||||
- (void)_didTapVideoButton:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"call_confirm"]) {
|
||||
NSLog(@"[SCInsta] Call confirm triggered");
|
||||
|
||||
if ([SCIUtils getBoolPref:@"video_call_confirm"]) {
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
|
||||
@@ -18,17 +18,22 @@
|
||||
// Follow button on profile page
|
||||
%hook IGFollowController
|
||||
- (void)_didPressFollowButton {
|
||||
// Get user follow status (check if already following user)
|
||||
NSInteger UserFollowStatus = self.user.followStatus;
|
||||
|
||||
// Only show confirm dialog if user is not following
|
||||
if (UserFollowStatus == 2) {
|
||||
NSInteger status = self.user.followStatus;
|
||||
if (status == 2) {
|
||||
CONFIRMFOLLOW(%orig);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
|
||||
// Unfollow from profile action sheet
|
||||
- (void)_performUnfollow {
|
||||
if ([SCIUtils getBoolPref:@"unfollow_confirm"]) {
|
||||
[SCIUtils showConfirmation:^(void) { %orig; } title:SCILocalized(@"Unfollow?")];
|
||||
} else {
|
||||
%orig;
|
||||
}
|
||||
}
|
||||
%end
|
||||
|
||||
// Follow button on discover people page
|
||||
|
||||
@@ -1,41 +1,81 @@
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// Reels like tap goes through a Swift class method on
|
||||
// IGSundialViewerLikeButtonActionHandler since IG 426.
|
||||
typedef void (*SciHandleTapFn)(Class, SEL, id, id, BOOL);
|
||||
typedef void (*SciHandleTapCompFn)(Class, SEL, id, id, BOOL, id);
|
||||
static SciHandleTapFn orig_sciHandleTap = NULL;
|
||||
static SciHandleTapCompFn orig_sciHandleTapComp = NULL;
|
||||
|
||||
// Confirmation handlers
|
||||
static void new_sciHandleTap(Class cls, SEL _cmd, id ctx, id btn, BOOL anim) {
|
||||
if (![SCIUtils getBoolPref:@"like_confirm_reels"]) {
|
||||
orig_sciHandleTap(cls, _cmd, ctx, btn, anim);
|
||||
return;
|
||||
}
|
||||
__strong id sCtx = ctx;
|
||||
__strong id sBtn = btn;
|
||||
[SCIUtils showConfirmation:^{
|
||||
@try { orig_sciHandleTap(cls, _cmd, sCtx, sBtn, anim); }
|
||||
@catch (__unused id e) {}
|
||||
}];
|
||||
}
|
||||
|
||||
#define CONFIRMPOSTLIKE(orig) \
|
||||
if ([SCIUtils getBoolPref:@"like_confirm"]) { \
|
||||
NSLog(@"[SCInsta] Confirm post like triggered"); \
|
||||
\
|
||||
[SCIUtils showConfirmation:^(void) { orig; }]; \
|
||||
} \
|
||||
else { \
|
||||
return orig; \
|
||||
} \
|
||||
// Copy the completion block — it's a stack block and won't survive the alert.
|
||||
static void new_sciHandleTapComp(Class cls, SEL _cmd, id ctx, id btn, BOOL anim, id comp) {
|
||||
if (![SCIUtils getBoolPref:@"like_confirm_reels"]) {
|
||||
orig_sciHandleTapComp(cls, _cmd, ctx, btn, anim, comp);
|
||||
return;
|
||||
}
|
||||
__strong id sCtx = ctx;
|
||||
__strong id sBtn = btn;
|
||||
id sComp = comp ? [comp copy] : nil;
|
||||
[SCIUtils showConfirmation:^{
|
||||
@try { orig_sciHandleTapComp(cls, _cmd, sCtx, sBtn, anim, sComp); }
|
||||
@catch (__unused id e) {}
|
||||
}];
|
||||
}
|
||||
|
||||
#define CONFIRMREELSLIKE(orig) \
|
||||
if ([SCIUtils getBoolPref:@"like_confirm_reels"]) { \
|
||||
NSLog(@"[SCInsta] Confirm reels like triggered"); \
|
||||
\
|
||||
[SCIUtils showConfirmation:^(void) { orig; }]; \
|
||||
} \
|
||||
else { \
|
||||
return orig; \
|
||||
} \
|
||||
__attribute__((constructor)) static void _sciHookReelsLikeHandler(void) {
|
||||
Class c = NSClassFromString(@"_TtC30IGSundialOverlayActionHandlers38IGSundialViewerLikeButtonActionHandler");
|
||||
if (!c) return;
|
||||
Class meta = object_getClass(c);
|
||||
SEL s1 = NSSelectorFromString(@"handleTapWithActionContext:likeButton:willPlayRingsCustomLikeAnimation:");
|
||||
SEL s2 = NSSelectorFromString(@"handleTapWithActionContext:likeButton:willPlayRingsCustomLikeAnimation:completion:");
|
||||
if (class_getClassMethod(c, s1))
|
||||
MSHookMessageEx(meta, s1, (IMP)new_sciHandleTap, (IMP *)&orig_sciHandleTap);
|
||||
if (class_getClassMethod(c, s2))
|
||||
MSHookMessageEx(meta, s2, (IMP)new_sciHandleTapComp, (IMP *)&orig_sciHandleTapComp);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
#define CONFIRMPOSTLIKE(orig) \
|
||||
if ([SCIUtils getBoolPref:@"like_confirm"]) \
|
||||
[SCIUtils showConfirmation:^(void) { orig; }]; \
|
||||
else return orig;
|
||||
|
||||
#define CONFIRMREELSLIKE(orig) \
|
||||
if ([SCIUtils getBoolPref:@"like_confirm_reels"]) \
|
||||
[SCIUtils showConfirmation:^(void) { orig; }]; \
|
||||
else return orig;
|
||||
|
||||
// Liking posts
|
||||
%hook IGUFIButtonBarView
|
||||
- (void)_onLikeButtonPressed:(id)arg1 {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
- (void)_onLikeButtonPressed {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
%end
|
||||
%hook IGFeedPhotoView
|
||||
- (void)_onDoubleTap:(id)arg1 {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
- (void)_onDoubleTap {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
%end
|
||||
%hook IGVideoPlayerOverlayContainerView
|
||||
- (void)_handleDoubleTapGesture:(id)arg1 {
|
||||
@@ -48,9 +88,6 @@
|
||||
- (void)controlsOverlayControllerDidTapLikeButton:(id)arg1 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
- (void)controlsOverlayControllerDidLongPressLikeButton:(id)arg1 gestureRecognizer:(id)arg2 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
- (void)gestureController:(id)arg1 didObserveDoubleTap:(id)arg2 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
@@ -62,6 +99,9 @@
|
||||
- (void)gestureController:(id)arg1 didObserveDoubleTap:(id)arg2 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
- (void)swift_photoCell:(id)arg1 didObserveDoubleTapWithLocationInfo:(id)arg2 gestureRecognizer:(id)arg3 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
%end
|
||||
%hook IGSundialViewerCarouselCell
|
||||
- (void)controlsOverlayControllerDidTapLikeButton:(id)arg1 {
|
||||
@@ -70,6 +110,9 @@
|
||||
- (void)gestureController:(id)arg1 didObserveDoubleTap:(id)arg2 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
- (void)carouselCell:(id)arg1 didObserveDoubleTapWithLocationInfo:(id)arg2 gestureRecognizer:(id)arg3 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
%end
|
||||
|
||||
// Liking comments
|
||||
@@ -96,53 +139,9 @@
|
||||
}
|
||||
%end
|
||||
|
||||
// Liking stories
|
||||
%hook IGStoryFullscreenDefaultFooterView
|
||||
- (void)_handleLikeTapped {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
- (void)_likeTapped {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
- (void)inputView:(id)arg1 didTapLikeButton:(id)arg2 {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
// Story like/emoji confirm handled by SCIStoryInteractionPipeline.
|
||||
|
||||
// For some stupid reason they removed the "liketapped" methods on newer Instagram versions
|
||||
// Now we have to do a shitty workaround instead :(
|
||||
// Works 99% of the time, but sometimes clicks get through directly to the like button (somehow)
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
|
||||
if (![SCIUtils getBoolPref:@"like_confirm"]) return;
|
||||
|
||||
UIButton *likeButton = [self valueForKey:@"likeButton"];
|
||||
if (!likeButton) return;
|
||||
|
||||
// 129115 = L(12) I(9) K(11) E(5)
|
||||
static NSInteger kOverlayTag = 129115;
|
||||
if ([likeButton viewWithTag:kOverlayTag]) return;
|
||||
|
||||
UIButton *overlay = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
overlay.tag = kOverlayTag;
|
||||
overlay.frame = likeButton.bounds;
|
||||
overlay.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
[overlay addTarget:self action:@selector(overlayTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[likeButton addSubview:overlay];
|
||||
}
|
||||
|
||||
%new - (void)overlayTapped:(UIButton *)overlay {
|
||||
UIButton *likeButton = (UIButton *)overlay.superview;
|
||||
|
||||
[SCIUtils showConfirmation:^{
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[likeButton sendActionsForControlEvents:UIControlEventTouchUpInside];
|
||||
});
|
||||
}];
|
||||
}
|
||||
%end
|
||||
|
||||
// DM like button (seems to be hidden)
|
||||
// DM like button
|
||||
%hook IGDirectThreadViewController
|
||||
- (void)_didTapLikeButton {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
#import "../../Utils.h"
|
||||
|
||||
%hook IGDirectDisappearingModeSwipeHandler
|
||||
- (void)handleBottomSwipeableScrollUpdate {
|
||||
if ([SCIUtils getBoolPref:@"disable_disappearing_mode_swipe"]) return;
|
||||
if ([SCIUtils getBoolPref:@"shh_mode_confirm"])
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
else %orig;
|
||||
}
|
||||
- (id)getSwipeableScrollHintTextInfo {
|
||||
if ([SCIUtils getBoolPref:@"disable_disappearing_mode_swipe"]) return nil;
|
||||
return %orig;
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGDirectThreadViewController
|
||||
- (void)swipeableScrollManagerDidEndDraggingAboveSwipeThreshold:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"shh_mode_confirm"]) {
|
||||
NSLog(@"[SCInsta] Confirm shh mode triggered");
|
||||
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)shhModeTransitionButtonDidTap:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"shh_mode_confirm"]) {
|
||||
NSLog(@"[SCInsta] Confirm shh mode triggered");
|
||||
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)messageListViewControllerDidToggleShhMode:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"shh_mode_confirm"]) {
|
||||
NSLog(@"[SCInsta] Confirm shh mode triggered");
|
||||
|
||||
if ([SCIUtils getBoolPref:@"shh_mode_confirm"])
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
else %orig;
|
||||
}
|
||||
%end
|
||||
|
||||
- (void)messageListViewControllerDidReplayInShhMode:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"shh_mode_confirm"])
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
else %orig;
|
||||
}
|
||||
%end
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
// Split by _analyticsModule: "highlight" substring → highlights toggle, else stories toggle.
|
||||
|
||||
static BOOL sciTapIsHighlight(id target) {
|
||||
Ivar iv = class_getInstanceVariable(object_getClass(target), "_analyticsModule");
|
||||
if (!iv) return NO;
|
||||
id v = nil;
|
||||
@try { v = object_getIvar(target, iv); } @catch (__unused id e) { return NO; }
|
||||
if (![v isKindOfClass:[NSString class]]) return NO;
|
||||
return [((NSString *)v).lowercaseString containsString:@"highlight"];
|
||||
}
|
||||
|
||||
%hook IGStoryViewerTapTarget
|
||||
- (void)_didTap:(id)arg1 forEvent:(id)arg2 {
|
||||
if ([SCIUtils getBoolPref:@"sticker_interact_confirm"]) {
|
||||
NSLog(@"[SCInsta] Confirm sticker interact triggered");
|
||||
|
||||
NSString *key = sciTapIsHighlight(self) ? @"sticker_interact_confirm_highlights" : @"sticker_interact_confirm";
|
||||
if ([SCIUtils getBoolPref:key]) {
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
%end
|
||||
%end
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Hooks installed iff sci_exp_flags_enabled.
|
||||
// Override: MetaLocalExperiment group{,Peek}Name — substring-match _experimentName, return "test"/nil.
|
||||
// View-only: IGMobileConfigContextManager get{Bool,Int64,Double,String}[:withDefault:] — record, no override.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "SCIExpFlags.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
// MetaLocalExperiment
|
||||
|
||||
static NSString *experimentNameOf(id obj) {
|
||||
if (!obj) return nil;
|
||||
Ivar iv = class_getInstanceVariable(object_getClass(obj), "_experimentName");
|
||||
if (!iv) return nil;
|
||||
@try {
|
||||
id v = object_getIvar(obj, iv);
|
||||
if ([v isKindOfClass:[NSString class]]) return v;
|
||||
} @catch (__unused id e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static id overrideGroupFor(NSString *expName, id origGroup) {
|
||||
if (!expName.length) return origGroup;
|
||||
NSString *lower = expName.lowercaseString;
|
||||
for (NSString *key in [SCIExpFlags allOverriddenNames]) {
|
||||
if (![lower containsString:key.lowercaseString]) continue;
|
||||
SCIExpFlagOverride o = [SCIExpFlags overrideForName:key];
|
||||
if (o == SCIExpFlagOverrideTrue) return @"test";
|
||||
if (o == SCIExpFlagOverrideFalse) return nil;
|
||||
}
|
||||
return origGroup;
|
||||
}
|
||||
|
||||
static id (*orig_groupName)(id, SEL);
|
||||
static id new_groupName(id self, SEL _cmd) {
|
||||
id orig = orig_groupName ? orig_groupName(self, _cmd) : nil;
|
||||
NSString *name = experimentNameOf(self);
|
||||
[SCIExpFlags recordExperimentName:name group:[orig isKindOfClass:[NSString class]] ? orig : nil];
|
||||
return overrideGroupFor(name, orig);
|
||||
}
|
||||
|
||||
static id (*orig_peekGroupName)(id, SEL);
|
||||
static id new_peekGroupName(id self, SEL _cmd) {
|
||||
id orig = orig_peekGroupName ? orig_peekGroupName(self, _cmd) : nil;
|
||||
NSString *name = experimentNameOf(self);
|
||||
[SCIExpFlags recordExperimentName:name group:[orig isKindOfClass:[NSString class]] ? orig : nil];
|
||||
return overrideGroupFor(name, orig);
|
||||
}
|
||||
|
||||
// IGMobileConfigContextManager — view-only.
|
||||
// param arg is {uint64} struct, ABI-identical to unsigned long long on arm64.
|
||||
|
||||
static BOOL (*orig_mcBool)(id, SEL, unsigned long long);
|
||||
static BOOL new_mcBool(id self, SEL _cmd, unsigned long long pid) {
|
||||
BOOL v = orig_mcBool(self, _cmd, pid);
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeBool defaultValue:v ? @"YES" : @"NO"];
|
||||
return v;
|
||||
}
|
||||
static BOOL (*orig_mcBool_def)(id, SEL, unsigned long long, BOOL);
|
||||
static BOOL new_mcBool_def(id self, SEL _cmd, unsigned long long pid, BOOL def) {
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeBool defaultValue:def ? @"YES" : @"NO"];
|
||||
return orig_mcBool_def(self, _cmd, pid, def);
|
||||
}
|
||||
static long long (*orig_mcInt)(id, SEL, unsigned long long);
|
||||
static long long new_mcInt(id self, SEL _cmd, unsigned long long pid) {
|
||||
long long v = orig_mcInt(self, _cmd, pid);
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeInt defaultValue:[NSString stringWithFormat:@"%lld", v]];
|
||||
return v;
|
||||
}
|
||||
static long long (*orig_mcInt_def)(id, SEL, unsigned long long, long long);
|
||||
static long long new_mcInt_def(id self, SEL _cmd, unsigned long long pid, long long def) {
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeInt defaultValue:[NSString stringWithFormat:@"%lld", def]];
|
||||
return orig_mcInt_def(self, _cmd, pid, def);
|
||||
}
|
||||
static double (*orig_mcDouble)(id, SEL, unsigned long long);
|
||||
static double new_mcDouble(id self, SEL _cmd, unsigned long long pid) {
|
||||
double v = orig_mcDouble(self, _cmd, pid);
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeDouble defaultValue:[NSString stringWithFormat:@"%f", v]];
|
||||
return v;
|
||||
}
|
||||
static double (*orig_mcDouble_def)(id, SEL, unsigned long long, double);
|
||||
static double new_mcDouble_def(id self, SEL _cmd, unsigned long long pid, double def) {
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeDouble defaultValue:[NSString stringWithFormat:@"%f", def]];
|
||||
return orig_mcDouble_def(self, _cmd, pid, def);
|
||||
}
|
||||
static id (*orig_mcString)(id, SEL, unsigned long long);
|
||||
static id new_mcString(id self, SEL _cmd, unsigned long long pid) {
|
||||
id v = orig_mcString(self, _cmd, pid);
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeString defaultValue:[v description] ?: @""];
|
||||
return v;
|
||||
}
|
||||
static id (*orig_mcString_def)(id, SEL, unsigned long long, id);
|
||||
static id new_mcString_def(id self, SEL _cmd, unsigned long long pid, id def) {
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeString defaultValue:[def description] ?: @""];
|
||||
return orig_mcString_def(self, _cmd, pid, def);
|
||||
}
|
||||
|
||||
// install
|
||||
|
||||
static void install(Class cls, NSString *selName, IMP newImp, IMP *origOut) {
|
||||
if (!cls) return;
|
||||
SEL s = NSSelectorFromString(selName);
|
||||
if (!class_getInstanceMethod(cls, s)) return;
|
||||
MSHookMessageEx(cls, s, newImp, origOut);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
if (![SCIUtils getBoolPref:@"sci_exp_flags_enabled"]) return;
|
||||
|
||||
if ([SCIExpFlags checkAndHandleCrashLoop]) {
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
[SCIUtils showToastForDuration:4.0 title:@"Exp flags reset after repeated crashes"];
|
||||
});
|
||||
}
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{ [SCIExpFlags markLaunchStable]; });
|
||||
|
||||
// Family inherits Meta — one install covers both
|
||||
Class meta = NSClassFromString(@"MetaLocalExperiment");
|
||||
install(meta, @"groupName", (IMP)new_groupName, (IMP *)&orig_groupName);
|
||||
install(meta, @"peekGroupName", (IMP)new_peekGroupName, (IMP *)&orig_peekGroupName);
|
||||
|
||||
Class mc = NSClassFromString(@"IGMobileConfigContextManager");
|
||||
install(mc, @"getBool:", (IMP)new_mcBool, (IMP *)&orig_mcBool);
|
||||
install(mc, @"getBool:withDefault:", (IMP)new_mcBool_def, (IMP *)&orig_mcBool_def);
|
||||
install(mc, @"getInt64:", (IMP)new_mcInt, (IMP *)&orig_mcInt);
|
||||
install(mc, @"getInt64:withDefault:", (IMP)new_mcInt_def, (IMP *)&orig_mcInt_def);
|
||||
install(mc, @"getDouble:", (IMP)new_mcDouble, (IMP *)&orig_mcDouble);
|
||||
install(mc, @"getDouble:withDefault:", (IMP)new_mcDouble_def, (IMP *)&orig_mcDouble_def);
|
||||
install(mc, @"getString:", (IMP)new_mcString, (IMP *)&orig_mcString);
|
||||
install(mc, @"getString:withDefault:", (IMP)new_mcString_def, (IMP *)&orig_mcString_def);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Exp flag override store + observation logs.
|
||||
// Override works only for MetaLocalExperiment (name-substring match on _experimentName).
|
||||
// MC reads + scanned names are view-only — no reliable name→ID mapping.
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
typedef NS_ENUM(NSInteger, SCIExpFlagOverride) {
|
||||
SCIExpFlagOverrideOff = 0,
|
||||
SCIExpFlagOverrideTrue = 1,
|
||||
SCIExpFlagOverrideFalse = 2,
|
||||
};
|
||||
|
||||
typedef NS_ENUM(NSInteger, SCIExpMCType) {
|
||||
SCIExpMCTypeBool,
|
||||
SCIExpMCTypeInt,
|
||||
SCIExpMCTypeDouble,
|
||||
SCIExpMCTypeString,
|
||||
};
|
||||
|
||||
@interface SCIExpObservation : NSObject
|
||||
@property (nonatomic, copy) NSString *experimentName;
|
||||
@property (nonatomic, copy) NSString *lastGroup;
|
||||
@property (nonatomic, assign) NSUInteger hitCount;
|
||||
@end
|
||||
|
||||
@interface SCIExpMCObservation : NSObject
|
||||
@property (nonatomic, assign) unsigned long long paramID;
|
||||
@property (nonatomic, assign) SCIExpMCType type;
|
||||
@property (nonatomic, copy) NSString *lastDefault;
|
||||
@property (nonatomic, assign) NSUInteger hitCount;
|
||||
@end
|
||||
|
||||
@interface SCIExpFlags : NSObject
|
||||
|
||||
// overrides (persisted)
|
||||
+ (SCIExpFlagOverride)overrideForName:(NSString *)name;
|
||||
+ (void)setOverride:(SCIExpFlagOverride)o forName:(NSString *)name;
|
||||
+ (NSArray<NSString *> *)allOverriddenNames;
|
||||
+ (void)resetAllOverrides;
|
||||
|
||||
// meta observations (live)
|
||||
+ (void)recordExperimentName:(NSString *)name group:(NSString *)group;
|
||||
+ (NSArray<SCIExpObservation *> *)allObservations;
|
||||
|
||||
// MC id observations (live, view-only)
|
||||
+ (void)recordMCParamID:(unsigned long long)pid type:(SCIExpMCType)t defaultValue:(NSString *)def;
|
||||
+ (NSArray<SCIExpMCObservation *> *)allMCObservations;
|
||||
|
||||
// binary-scanned names (bg, cb on main)
|
||||
+ (void)scanExecutableNamesWithCompletion:(void (^)(NSArray<NSString *> *names))completion;
|
||||
|
||||
// crash-loop guard — 3 bad launches wipe overrides
|
||||
+ (BOOL)checkAndHandleCrashLoop;
|
||||
+ (void)markLaunchStable;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,187 @@
|
||||
#import "SCIExpFlags.h"
|
||||
#import <sys/mman.h>
|
||||
#import <sys/stat.h>
|
||||
#import <fcntl.h>
|
||||
|
||||
static NSString *const kOverridesKey = @"sci_exp_overrides_by_name";
|
||||
static NSString *const kCrashCounterKey = @"sci_exp_flags_unstable_launches";
|
||||
static const NSInteger kCrashThreshold = 3;
|
||||
|
||||
@implementation SCIExpObservation
|
||||
@end
|
||||
@implementation SCIExpMCObservation
|
||||
@end
|
||||
|
||||
@implementation SCIExpFlags
|
||||
|
||||
// overrides
|
||||
|
||||
+ (NSMutableDictionary *)loadOverrides {
|
||||
NSDictionary *d = [[NSUserDefaults standardUserDefaults] dictionaryForKey:kOverridesKey];
|
||||
return d ? [d mutableCopy] : [NSMutableDictionary dictionary];
|
||||
}
|
||||
|
||||
+ (void)saveOverrides:(NSDictionary *)d {
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
if (d.count == 0) [ud removeObjectForKey:kOverridesKey];
|
||||
else [ud setObject:d forKey:kOverridesKey];
|
||||
}
|
||||
|
||||
+ (SCIExpFlagOverride)overrideForName:(NSString *)name {
|
||||
if (!name.length) return SCIExpFlagOverrideOff;
|
||||
NSNumber *n = [self loadOverrides][name];
|
||||
return n ? (SCIExpFlagOverride)n.integerValue : SCIExpFlagOverrideOff;
|
||||
}
|
||||
|
||||
+ (void)setOverride:(SCIExpFlagOverride)o forName:(NSString *)name {
|
||||
if (!name.length) return;
|
||||
NSMutableDictionary *d = [self loadOverrides];
|
||||
if (o == SCIExpFlagOverrideOff) [d removeObjectForKey:name];
|
||||
else d[name] = @(o);
|
||||
[self saveOverrides:d];
|
||||
}
|
||||
|
||||
+ (NSArray<NSString *> *)allOverriddenNames { return [[self loadOverrides] allKeys]; }
|
||||
+ (void)resetAllOverrides { [[NSUserDefaults standardUserDefaults] removeObjectForKey:kOverridesKey]; }
|
||||
|
||||
// meta observations
|
||||
|
||||
static NSMutableDictionary<NSString *, SCIExpObservation *> *gMetaObs = nil;
|
||||
static dispatch_queue_t metaQueue(void) {
|
||||
static dispatch_queue_t q;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ q = dispatch_queue_create("sci.expflags.meta", DISPATCH_QUEUE_CONCURRENT); });
|
||||
return q;
|
||||
}
|
||||
|
||||
+ (void)recordExperimentName:(NSString *)name group:(NSString *)group {
|
||||
if (!name.length) return;
|
||||
dispatch_barrier_async(metaQueue(), ^{
|
||||
if (!gMetaObs) gMetaObs = [NSMutableDictionary dictionary];
|
||||
SCIExpObservation *o = gMetaObs[name];
|
||||
if (!o) { o = [SCIExpObservation new]; o.experimentName = name; gMetaObs[name] = o; }
|
||||
o.lastGroup = group;
|
||||
o.hitCount++;
|
||||
});
|
||||
}
|
||||
|
||||
+ (NSArray<SCIExpObservation *> *)allObservations {
|
||||
__block NSArray *snap = @[];
|
||||
dispatch_sync(metaQueue(), ^{ snap = gMetaObs ? [gMetaObs.allValues copy] : @[]; });
|
||||
return [snap sortedArrayUsingComparator:^NSComparisonResult(SCIExpObservation *a, SCIExpObservation *b) {
|
||||
return [a.experimentName caseInsensitiveCompare:b.experimentName];
|
||||
}];
|
||||
}
|
||||
|
||||
// MC observations (view-only)
|
||||
|
||||
static NSMutableDictionary<NSNumber *, SCIExpMCObservation *> *gMCObs = nil;
|
||||
static dispatch_queue_t mcQueue(void) {
|
||||
static dispatch_queue_t q;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ q = dispatch_queue_create("sci.expflags.mc", DISPATCH_QUEUE_CONCURRENT); });
|
||||
return q;
|
||||
}
|
||||
|
||||
+ (void)recordMCParamID:(unsigned long long)pid type:(SCIExpMCType)t defaultValue:(NSString *)def {
|
||||
dispatch_barrier_async(mcQueue(), ^{
|
||||
if (!gMCObs) gMCObs = [NSMutableDictionary dictionary];
|
||||
NSNumber *k = @(pid);
|
||||
SCIExpMCObservation *o = gMCObs[k];
|
||||
if (!o) { o = [SCIExpMCObservation new]; o.paramID = pid; o.type = t; gMCObs[k] = o; }
|
||||
o.lastDefault = def ?: @"";
|
||||
o.hitCount++;
|
||||
});
|
||||
}
|
||||
|
||||
+ (NSArray<SCIExpMCObservation *> *)allMCObservations {
|
||||
__block NSArray *snap = @[];
|
||||
dispatch_sync(mcQueue(), ^{ snap = gMCObs ? [gMCObs.allValues copy] : @[]; });
|
||||
// hot flags first
|
||||
return [snap sortedArrayUsingComparator:^NSComparisonResult(SCIExpMCObservation *a, SCIExpMCObservation *b) {
|
||||
if (a.hitCount != b.hitCount) return a.hitCount > b.hitCount ? NSOrderedAscending : NSOrderedDescending;
|
||||
if (a.paramID < b.paramID) return NSOrderedAscending;
|
||||
if (a.paramID > b.paramID) return NSOrderedDescending;
|
||||
return NSOrderedSame;
|
||||
}];
|
||||
}
|
||||
|
||||
// crash-loop guard
|
||||
|
||||
+ (BOOL)checkAndHandleCrashLoop {
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
NSInteger c = [ud integerForKey:kCrashCounterKey] + 1;
|
||||
if (c >= kCrashThreshold && [self loadOverrides].count > 0) {
|
||||
[self resetAllOverrides];
|
||||
[ud removeObjectForKey:kCrashCounterKey];
|
||||
return YES;
|
||||
}
|
||||
[ud setInteger:c forKey:kCrashCounterKey];
|
||||
return NO;
|
||||
}
|
||||
|
||||
+ (void)markLaunchStable { [[NSUserDefaults standardUserDefaults] removeObjectForKey:kCrashCounterKey]; }
|
||||
|
||||
// binary scan — mmap executable, grep for flag-prefix strings, dedupe/sort
|
||||
|
||||
+ (void)scanExecutableNamesWithCompletion:(void (^)(NSArray<NSString *> *))completion {
|
||||
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
|
||||
NSArray *names = [self scanExecutable];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ completion(names ?: @[]); });
|
||||
});
|
||||
}
|
||||
|
||||
+ (NSArray<NSString *> *)scanExecutable {
|
||||
NSString *path = [[NSBundle mainBundle] executablePath];
|
||||
if (!path) return @[];
|
||||
int fd = open(path.UTF8String, O_RDONLY);
|
||||
if (fd < 0) return @[];
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) != 0 || st.st_size <= 0) { close(fd); return @[]; }
|
||||
size_t size = (size_t)st.st_size;
|
||||
const char *base = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
|
||||
close(fd);
|
||||
if (base == MAP_FAILED) return @[];
|
||||
|
||||
// Meta flag/analytics name prefixes
|
||||
static const char *prefixes[] = {
|
||||
"ig_ios_", "ig_android_", "ig_direct_", "ig_feed_", "ig_reels_",
|
||||
"ig_stories_", "ig_explore_", "ig_camera_", "ig_growth_", "ig_privacy_",
|
||||
"fbios_", "fb_ios_"
|
||||
};
|
||||
const size_t pc = sizeof(prefixes) / sizeof(prefixes[0]);
|
||||
NSMutableSet *seen = [NSMutableSet set];
|
||||
|
||||
for (size_t i = 0; i < size; i++) {
|
||||
char c = base[i];
|
||||
if (c != 'i' && c != 'f') continue;
|
||||
if (i > 0) {
|
||||
char prev = base[i - 1];
|
||||
if ((prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9') || prev == '_' || prev == '.') continue;
|
||||
}
|
||||
size_t matched = 0;
|
||||
const char *rem = base + i;
|
||||
size_t left = size - i;
|
||||
for (size_t p = 0; p < pc; p++) {
|
||||
size_t L = strlen(prefixes[p]);
|
||||
if (left >= L && memcmp(rem, prefixes[p], L) == 0) { matched = L; break; }
|
||||
}
|
||||
if (!matched) continue;
|
||||
size_t j = i + matched;
|
||||
while (j < size) {
|
||||
char ch = base[j];
|
||||
if (!((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.')) break;
|
||||
j++;
|
||||
}
|
||||
size_t nl = j - i;
|
||||
if (nl >= 16 && nl <= 160) {
|
||||
NSString *s = [[NSString alloc] initWithBytes:(base + i) length:nl encoding:NSASCIIStringEncoding];
|
||||
if (s) [seen addObject:s];
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
munmap((void *)base, size);
|
||||
return [[seen allObjects] sortedArrayUsingSelector:@selector(compare:)];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,60 @@
|
||||
// Direct Notes experimental reply types + friend map. Gates: igt_directnotes_*.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
#include "../../../modules/fishhook/fishhook.h"
|
||||
|
||||
static inline BOOL prefFriendMap(void) { return [SCIUtils getBoolPref:@"igt_directnotes_friendmap"]; }
|
||||
static inline BOOL prefAudio(void) { return [SCIUtils getBoolPref:@"igt_directnotes_audio_reply"]; }
|
||||
static inline BOOL prefAvatar(void) { return [SCIUtils getBoolPref:@"igt_directnotes_avatar_reply"]; }
|
||||
static inline BOOL prefGifs(void) { return [SCIUtils getBoolPref:@"igt_directnotes_gifs_reply"]; }
|
||||
static inline BOOL prefPhoto(void) { return [SCIUtils getBoolPref:@"igt_directnotes_photo_reply"]; }
|
||||
|
||||
static BOOL rep_friendmap(void) { return prefFriendMap(); }
|
||||
static BOOL rep_audio(void) { return prefAudio(); }
|
||||
static BOOL rep_avatar(void) { return prefAvatar(); }
|
||||
static BOOL rep_gifs(void) { return prefGifs(); }
|
||||
static BOOL rep_photo(void) { return prefPhoto(); }
|
||||
|
||||
static inline BOOL containsAny(NSString *s, NSArray<NSString *> *needles) {
|
||||
if (![s isKindOfClass:[NSString class]] || s.length == 0) return NO;
|
||||
NSString *lower = s.lowercaseString;
|
||||
for (NSString *n in needles) if ([lower containsString:n]) return YES;
|
||||
return NO;
|
||||
}
|
||||
|
||||
static BOOL matchesDirectNotes(NSString *name) {
|
||||
if (prefFriendMap() && containsAny(name, @[@"friendmap", @"friends_map",
|
||||
@"ig_ios_friendmap_", @"friendmapenabled"])) return YES;
|
||||
if (prefAudio() && containsAny(name, @[@"audio"])) return YES;
|
||||
if (prefAvatar() && containsAny(name, @[@"avatar"])) return YES;
|
||||
if (prefGifs() && containsAny(name, @[@"gifs", @"sticker"])) return YES;
|
||||
if (prefPhoto() && containsAny(name, @[@"photo"])) return YES;
|
||||
return NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_isIn)(id, SEL, id) = NULL;
|
||||
static BOOL new_isIn(id self, SEL _cmd, id name) {
|
||||
if (matchesDirectNotes(name)) return YES;
|
||||
return orig_isIn ? orig_isIn(self, _cmd, name) : NO;
|
||||
}
|
||||
|
||||
%ctor {
|
||||
if (!(prefFriendMap() || prefAudio() || prefAvatar() || prefGifs() || prefPhoto())) return;
|
||||
|
||||
struct rebinding binds[] = {
|
||||
{"IGDirectNotesFriendMapEnabled", (void *)rep_friendmap, NULL},
|
||||
{"IGDirectNotesEnableAudioNoteReplyType", (void *)rep_audio, NULL},
|
||||
{"IGDirectNotesEnableAvatarReplyTypes", (void *)rep_avatar, NULL},
|
||||
{"IGDirectNotesEnableGifsStickersReplyTypes", (void *)rep_gifs, NULL},
|
||||
{"IGDirectNotesEnablePhotoNoteReplyType", (void *)rep_photo, NULL},
|
||||
};
|
||||
rebind_symbols(binds, sizeof(binds) / sizeof(binds[0]));
|
||||
|
||||
Class helper = NSClassFromString(@"IGDirectNotesExperimentHelper");
|
||||
SEL sel = NSSelectorFromString(@"isInExperiment:");
|
||||
if (helper && class_getInstanceMethod(helper, sel)) {
|
||||
MSHookMessageEx(helper, sel, (IMP)new_isIn, (IMP *)&orig_isIn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Experiment-name substring override. Gates: igt_quicksnap, igt_directnotes_friendmap, igt_prism.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static inline BOOL containsAny(NSString *s, NSArray<NSString *> *needles) {
|
||||
if (![s isKindOfClass:[NSString class]] || s.length == 0) return NO;
|
||||
NSString *lower = s.lowercaseString;
|
||||
for (NSString *n in needles) if ([lower containsString:n]) return YES;
|
||||
return NO;
|
||||
}
|
||||
|
||||
static BOOL matchQuickSnap(NSString *name) {
|
||||
if (![SCIUtils getBoolPref:@"igt_quicksnap"]) return NO;
|
||||
return containsAny(name, @[@"quicksnap", @"quick_snap", @"instants", @"xma_quicksnap",
|
||||
@"_ig_ios_quicksnap_", @"_ig_ios_quick_snap_", @"_ig_ios_instants_"]);
|
||||
}
|
||||
|
||||
static BOOL matchFriendMap(NSString *name) {
|
||||
if (![SCIUtils getBoolPref:@"igt_directnotes_friendmap"]) return NO;
|
||||
return containsAny(name, @[@"friendmap", @"friends_map", @"direct_notes",
|
||||
@"ig_direct_notes_ios", @"_ig_ios_friendmap_", @"_ig_ios_friends_map_"]);
|
||||
}
|
||||
|
||||
static BOOL matchPrism(NSString *name) {
|
||||
if (![SCIUtils getBoolPref:@"igt_prism"]) return NO;
|
||||
return containsAny(name, @[@"prism"]);
|
||||
}
|
||||
|
||||
static inline BOOL shouldForceOn(NSString *name) {
|
||||
return matchQuickSnap(name) || matchFriendMap(name) || matchPrism(name);
|
||||
}
|
||||
|
||||
static NSString *expNameOf(id obj) {
|
||||
if (!obj) return nil;
|
||||
Ivar iv = class_getInstanceVariable(object_getClass(obj), "_experimentGroupName");
|
||||
if (!iv) iv = class_getInstanceVariable(object_getClass(obj), "_experimentName");
|
||||
if (!iv) return nil;
|
||||
@try {
|
||||
id v = object_getIvar(obj, iv);
|
||||
if ([v isKindOfClass:[NSString class]]) return v;
|
||||
} @catch (__unused id e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static BOOL (*orig_meta_isIn)(id, SEL) = NULL;
|
||||
static BOOL new_meta_isIn(id self, SEL _cmd) {
|
||||
if (shouldForceOn(expNameOf(self))) return YES;
|
||||
return orig_meta_isIn ? orig_meta_isIn(self, _cmd) : NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_family_isIn)(id, SEL) = NULL;
|
||||
static BOOL new_family_isIn(id self, SEL _cmd) {
|
||||
if (shouldForceOn(expNameOf(self))) return YES;
|
||||
return orig_family_isIn ? orig_family_isIn(self, _cmd) : NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_lid_enabled)(id, SEL, NSString *) = NULL;
|
||||
static BOOL new_lid_enabled(id self, SEL _cmd, NSString *name) {
|
||||
if (shouldForceOn(name)) return YES;
|
||||
return orig_lid_enabled ? orig_lid_enabled(self, _cmd, name) : NO;
|
||||
}
|
||||
|
||||
static id (*orig_groupName)(id, SEL) = NULL;
|
||||
static id new_groupName(id self, SEL _cmd) {
|
||||
if (shouldForceOn(expNameOf(self))) return @"test";
|
||||
return orig_groupName ? orig_groupName(self, _cmd) : nil;
|
||||
}
|
||||
|
||||
static id (*orig_peekGroup)(id, SEL) = NULL;
|
||||
static id new_peekGroup(id self, SEL _cmd) {
|
||||
if (shouldForceOn(expNameOf(self))) return @"test";
|
||||
return orig_peekGroup ? orig_peekGroup(self, _cmd) : nil;
|
||||
}
|
||||
|
||||
static void hook(Class cls, NSString *selName, IMP newImp, IMP *origOut) {
|
||||
if (!cls) return;
|
||||
SEL s = NSSelectorFromString(selName);
|
||||
if (!class_getInstanceMethod(cls, s)) return;
|
||||
MSHookMessageEx(cls, s, newImp, origOut);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
if (!([SCIUtils getBoolPref:@"igt_quicksnap"] ||
|
||||
[SCIUtils getBoolPref:@"igt_directnotes_friendmap"] ||
|
||||
[SCIUtils getBoolPref:@"igt_prism"])) return;
|
||||
|
||||
Class meta = NSClassFromString(@"MetaLocalExperiment");
|
||||
hook(meta, @"isInExperiment", (IMP)new_meta_isIn, (IMP *)&orig_meta_isIn);
|
||||
hook(meta, @"groupName", (IMP)new_groupName, (IMP *)&orig_groupName);
|
||||
hook(meta, @"peekGroupName", (IMP)new_peekGroup, (IMP *)&orig_peekGroup);
|
||||
hook(NSClassFromString(@"FamilyLocalExperiment"), @"isInExperiment",
|
||||
(IMP)new_family_isIn, (IMP *)&orig_family_isIn);
|
||||
hook(NSClassFromString(@"LIDExperimentGenerator"), @"isExperimentEnabled:",
|
||||
(IMP)new_lid_enabled, (IMP *)&orig_lid_enabled);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Force-enable Homecoming nav experiment. Gate: igt_homecoming.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static NSString *expNameOf(id obj) {
|
||||
if (!obj) return nil;
|
||||
Ivar iv = class_getInstanceVariable(object_getClass(obj), "_experimentGroupName");
|
||||
if (!iv) iv = class_getInstanceVariable(object_getClass(obj), "_experimentName");
|
||||
if (!iv) return nil;
|
||||
@try {
|
||||
id v = object_getIvar(obj, iv);
|
||||
if ([v isKindOfClass:[NSString class]]) return v;
|
||||
} @catch (__unused id e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static inline BOOL matchesHomecoming(NSString *s) {
|
||||
return [s isKindOfClass:[NSString class]] && s.length &&
|
||||
[s.lowercaseString containsString:@"homecoming"];
|
||||
}
|
||||
|
||||
static BOOL (*orig_meta_isIn)(id, SEL) = NULL;
|
||||
static BOOL new_meta_isIn(id self, SEL _cmd) {
|
||||
if (matchesHomecoming(expNameOf(self))) return YES;
|
||||
return orig_meta_isIn ? orig_meta_isIn(self, _cmd) : NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_family_isIn)(id, SEL) = NULL;
|
||||
static BOOL new_family_isIn(id self, SEL _cmd) {
|
||||
if (matchesHomecoming(expNameOf(self))) return YES;
|
||||
return orig_family_isIn ? orig_family_isIn(self, _cmd) : NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_lid_enabled)(id, SEL, NSString *) = NULL;
|
||||
static BOOL new_lid_enabled(id self, SEL _cmd, NSString *name) {
|
||||
if (matchesHomecoming(name)) return YES;
|
||||
return orig_lid_enabled ? orig_lid_enabled(self, _cmd, name) : NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_nav_isHC)(id, SEL) = NULL;
|
||||
static BOOL new_nav_isHC(id self, SEL _cmd) { return YES; }
|
||||
|
||||
static void hook(Class cls, NSString *selName, IMP newImp, IMP *origOut) {
|
||||
if (!cls) return;
|
||||
SEL s = NSSelectorFromString(selName);
|
||||
if (!class_getInstanceMethod(cls, s)) return;
|
||||
MSHookMessageEx(cls, s, newImp, origOut);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
if (![SCIUtils getBoolPref:@"igt_homecoming"]) return;
|
||||
|
||||
hook(NSClassFromString(@"MetaLocalExperiment"), @"isInExperiment",
|
||||
(IMP)new_meta_isIn, (IMP *)&orig_meta_isIn);
|
||||
hook(NSClassFromString(@"FamilyLocalExperiment"), @"isInExperiment",
|
||||
(IMP)new_family_isIn, (IMP *)&orig_family_isIn);
|
||||
hook(NSClassFromString(@"LIDExperimentGenerator"), @"isExperimentEnabled:",
|
||||
(IMP)new_lid_enabled, (IMP *)&orig_lid_enabled);
|
||||
hook(NSClassFromString(@"_TtC18IGNavConfiguration18IGNavConfiguration"),
|
||||
@"isHomecomingEnabled", (IMP)new_nav_isHC, (IMP *)&orig_nav_isHC);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Force-enable QuickSnap (Instants) surfaces. Gate: igt_quicksnap.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
#define QS_BOOL1_RETURN_YES(fnName) \
|
||||
static BOOL (*orig_##fnName)(id, SEL, id) = NULL; \
|
||||
static BOOL new_##fnName(id self, SEL _cmd, id arg) { return YES; }
|
||||
|
||||
QS_BOOL1_RETURN_YES(qs_enabled)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_feed)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_inbox)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_stories)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_peek)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_tray)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_tray_peek)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_tray_pog)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_empty_pog)
|
||||
QS_BOOL1_RETURN_YES(qs_isqp)
|
||||
|
||||
static BOOL (*orig_qs_corner)(id, SEL) = NULL;
|
||||
static BOOL new_qs_corner(id self, SEL _cmd) { return YES; }
|
||||
static BOOL (*orig_qs_dialog)(id, SEL) = NULL;
|
||||
static BOOL new_qs_dialog(id self, SEL _cmd) { return YES; }
|
||||
|
||||
static BOOL (*orig_peek)(id, SEL) = NULL;
|
||||
static BOOL new_peek(id self, SEL _cmd) { return YES; }
|
||||
static BOOL (*orig_recap)(id, SEL) = NULL;
|
||||
static BOOL new_recap(id self, SEL _cmd) { return YES; }
|
||||
static BOOL (*orig__recap)(id, SEL) = NULL;
|
||||
static BOOL new__recap(id self, SEL _cmd) { return YES; }
|
||||
static BOOL (*orig_recap_media)(id, SEL) = NULL;
|
||||
static BOOL new_recap_media(id self, SEL _cmd) { return YES; }
|
||||
static BOOL (*orig_recap_video)(id, SEL) = NULL;
|
||||
static BOOL new_recap_video(id self, SEL _cmd) { return YES; }
|
||||
static BOOL (*orig_hidden)(id, SEL) = NULL;
|
||||
static BOOL new_hidden(id self, SEL _cmd) { return NO; }
|
||||
static BOOL (*orig__hidden)(id, SEL) = NULL;
|
||||
static BOOL new__hidden(id self, SEL _cmd) { return NO; }
|
||||
|
||||
static void hookClassMethod(NSString *cn, NSString *sn, IMP impl, IMP *orig) {
|
||||
Class cls = NSClassFromString(cn);
|
||||
if (!cls) return;
|
||||
Class meta = object_getClass(cls);
|
||||
if (!meta) return;
|
||||
SEL sel = NSSelectorFromString(sn);
|
||||
if (!class_getInstanceMethod(meta, sel)) return;
|
||||
MSHookMessageEx(meta, sel, impl, orig);
|
||||
}
|
||||
|
||||
static void hookInstance(NSString *cn, NSString *sn, IMP impl, IMP *orig) {
|
||||
Class cls = NSClassFromString(cn);
|
||||
if (!cls) return;
|
||||
SEL sel = NSSelectorFromString(sn);
|
||||
if (!class_getInstanceMethod(cls, sel)) return;
|
||||
MSHookMessageEx(cls, sel, impl, orig);
|
||||
}
|
||||
|
||||
static void hookZeroArgAcross(NSArray<NSString *> *classes, NSString *sn, IMP impl, IMP *orig) {
|
||||
SEL sel = NSSelectorFromString(sn);
|
||||
for (NSString *cn in classes) {
|
||||
Class cls = NSClassFromString(cn);
|
||||
if (!cls || !class_getInstanceMethod(cls, sel)) continue;
|
||||
MSHookMessageEx(cls, sel, impl, orig);
|
||||
}
|
||||
}
|
||||
|
||||
%ctor {
|
||||
if (![SCIUtils getBoolPref:@"igt_quicksnap"]) return;
|
||||
|
||||
NSString *helper = @"_TtC26IGQuickSnapExperimentation32IGQuickSnapExperimentationHelper";
|
||||
hookClassMethod(helper, @"isQuicksnapEnabled:", (IMP)new_qs_enabled, (IMP *)&orig_qs_enabled);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledInFeed:", (IMP)new_qs_enabled_feed, (IMP *)&orig_qs_enabled_feed);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledInInbox:", (IMP)new_qs_enabled_inbox, (IMP *)&orig_qs_enabled_inbox);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledInStories:", (IMP)new_qs_enabled_stories, (IMP *)&orig_qs_enabled_stories);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledInNotesTray:", (IMP)new_qs_enabled_tray, (IMP *)&orig_qs_enabled_tray);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledInNotesTrayWithPeek:", (IMP)new_qs_enabled_tray_peek, (IMP *)&orig_qs_enabled_tray_peek);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledInNotesTrayWithPog:", (IMP)new_qs_enabled_tray_pog, (IMP *)&orig_qs_enabled_tray_pog);
|
||||
hookClassMethod(helper, @"isQuicksnapNotesTrayEmptyPogEnabled:", (IMP)new_qs_enabled_empty_pog, (IMP *)&orig_qs_enabled_empty_pog);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledAsPeek:", (IMP)new_qs_enabled_peek, (IMP *)&orig_qs_enabled_peek);
|
||||
|
||||
NSString *tray = @"_TtC21IGNotesTrayController21IGNotesTrayController";
|
||||
hookInstance(tray, @"_isEligibleForQuicksnapCornerStackTransitionDialog", (IMP)new_qs_corner, (IMP *)&orig_qs_corner);
|
||||
hookInstance(tray, @"_isEligibleForQuicksnapDialog", (IMP)new_qs_dialog, (IMP *)&orig_qs_dialog);
|
||||
hookInstance(tray, @"isQPEnabled:", (IMP)new_qs_isqp, (IMP *)&orig_qs_isqp);
|
||||
|
||||
hookInstance(@"IGDirectNotesTrayRowSectionController", @"isQPEnabled:", (IMP)new_qs_isqp, NULL);
|
||||
hookInstance(@"_TtC24IGDirectNotesTrayUISwift37IGDirectNotesTrayRowSectionController",
|
||||
@"isQPEnabled:", (IMP)new_qs_isqp, NULL);
|
||||
|
||||
NSArray *instantsClasses = @[
|
||||
@"IGInstantGestureRecognizer",
|
||||
@"IGAPIQuickSnapData",
|
||||
@"XDTQuickSnapData",
|
||||
@"IGAPIQuicksnapRecapMediaInfo",
|
||||
@"XDTQuicksnapRecapMediaInfo",
|
||||
];
|
||||
hookZeroArgAcross(instantsClasses, @"isEligibleForPeek", (IMP)new_peek, (IMP *)&orig_peek);
|
||||
hookZeroArgAcross(instantsClasses, @"isQuicksnapRecap", (IMP)new_recap, (IMP *)&orig_recap);
|
||||
hookZeroArgAcross(instantsClasses, @"_isQuicksnapRecap", (IMP)new__recap, (IMP *)&orig__recap);
|
||||
hookZeroArgAcross(instantsClasses, @"hasQuicksnapRecapMedia",(IMP)new_recap_media, (IMP *)&orig_recap_media);
|
||||
hookZeroArgAcross(instantsClasses, @"isInstantsRecapVideo", (IMP)new_recap_video, (IMP *)&orig_recap_video);
|
||||
|
||||
NSString *svc = @"_TtC18IGQuickSnapService18IGQuickSnapService";
|
||||
hookInstance(svc, @"isHiddenByServer", (IMP)new_hidden, (IMP *)&orig_hidden);
|
||||
hookInstance(svc, @"_isHiddenByServer", (IMP)new__hidden, (IMP *)&orig__hidden);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// MobileConfig override for any ig_boolForKey: naming QuickSnap. Gate: igt_quicksnap.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static inline BOOL keyMatchesQuickSnap(id key) {
|
||||
if (![key isKindOfClass:[NSString class]]) return NO;
|
||||
NSString *s = ((NSString *)key).lowercaseString;
|
||||
return [s containsString:@"quicksnap"] ||
|
||||
[s containsString:@"quick_snap"] ||
|
||||
[s containsString:@"instants"] ||
|
||||
[s containsString:@"xma_quicksnap"];
|
||||
}
|
||||
|
||||
static BOOL (*orig_bool_key)(id, SEL, id) = NULL;
|
||||
static BOOL new_bool_key(id self, SEL _cmd, id key) {
|
||||
if (keyMatchesQuickSnap(key)) return YES;
|
||||
return orig_bool_key ? orig_bool_key(self, _cmd, key) : NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_bool_key_def)(id, SEL, id, BOOL) = NULL;
|
||||
static BOOL new_bool_key_def(id self, SEL _cmd, id key, BOOL def) {
|
||||
if (keyMatchesQuickSnap(key)) return YES;
|
||||
return orig_bool_key_def ? orig_bool_key_def(self, _cmd, key, def) : def;
|
||||
}
|
||||
|
||||
static void hookInstance(NSString *cn, NSString *sn, IMP impl, IMP *orig) {
|
||||
Class cls = NSClassFromString(cn);
|
||||
if (!cls) return;
|
||||
SEL sel = NSSelectorFromString(sn);
|
||||
if (!class_getInstanceMethod(cls, sel)) return;
|
||||
MSHookMessageEx(cls, sel, impl, orig);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
if (![SCIUtils getBoolPref:@"igt_quicksnap"]) return;
|
||||
|
||||
hookInstance(@"IGMobileConfigContextManager", @"ig_boolForKey:", (IMP)new_bool_key, (IMP *)&orig_bool_key);
|
||||
hookInstance(@"IGMobileConfigContextManager", @"ig_boolForKey:defaultValue:", (IMP)new_bool_key_def, (IMP *)&orig_bool_key_def);
|
||||
hookInstance(@"IGMobileConfigUserSessionContextManager", @"ig_boolForKey:", (IMP)new_bool_key, (IMP *)&orig_bool_key);
|
||||
hookInstance(@"IGMobileConfigUserSessionContextManager", @"ig_boolForKey:defaultValue:", (IMP)new_bool_key_def, (IMP *)&orig_bool_key_def);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Crash-loop guard + pref registry for igt_* experimental flags.
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@interface SCIExperimentalGuard : NSObject
|
||||
|
||||
+ (NSArray<NSString *> *)allPrefKeys;
|
||||
+ (BOOL)anyEnabled;
|
||||
+ (void)resetAll;
|
||||
+ (BOOL)didResetThisLaunch;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,68 @@
|
||||
#import "SCIExperimentalGuard.h"
|
||||
#import "../../Utils.h"
|
||||
|
||||
static NSString *const kCounterKey = @"sci_exp_unstable_launches";
|
||||
static NSInteger const kThreshold = 3;
|
||||
static BOOL gDidReset = NO;
|
||||
|
||||
@implementation SCIExperimentalGuard
|
||||
|
||||
+ (NSArray<NSString *> *)allPrefKeys {
|
||||
static NSArray *keys;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
keys = @[
|
||||
@"igt_homecoming",
|
||||
@"igt_quicksnap",
|
||||
@"igt_prism",
|
||||
@"igt_directnotes_friendmap",
|
||||
@"igt_directnotes_audio_reply",
|
||||
@"igt_directnotes_avatar_reply",
|
||||
@"igt_directnotes_gifs_reply",
|
||||
@"igt_directnotes_photo_reply",
|
||||
];
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
+ (BOOL)anyEnabled {
|
||||
for (NSString *k in [self allPrefKeys]) {
|
||||
if ([SCIUtils getBoolPref:k]) return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
+ (void)resetAll {
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
for (NSString *k in [self allPrefKeys]) [ud setBool:NO forKey:k];
|
||||
}
|
||||
|
||||
+ (BOOL)didResetThisLaunch { return gDidReset; }
|
||||
|
||||
+ (void)load {
|
||||
if (![self anyEnabled]) return;
|
||||
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
NSInteger c = [ud integerForKey:kCounterKey] + 1;
|
||||
|
||||
if (c >= kThreshold) {
|
||||
[self resetAll];
|
||||
[ud removeObjectForKey:kCounterKey];
|
||||
gDidReset = YES;
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
[SCIUtils showToastForDuration:5.0
|
||||
title:SCILocalized(@"Experimental flags reset")
|
||||
subtitle:SCILocalized(@"Disabled after repeated crashes.")];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
[ud setInteger:c forKey:kCounterKey];
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
[[NSUserDefaults standardUserDefaults] removeObjectForKey:kCounterKey];
|
||||
});
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,32 +1,61 @@
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
// IGFeedPlayback.IGFeedPlaybackStrategy gained new init parameters in IG 423+.
|
||||
// Both the 2-arg and 3-arg variants are hooked to force shouldDisableAutoplay=YES.
|
||||
// Hooked via MSHookMessageEx in %ctor since the class has a Swift-mangled name.
|
||||
// IGFeedPlayback.IGFeedPlaybackStrategy has a Swift-mangled name. Both init
|
||||
// variants force shouldDisableAutoplay=YES when the pref is on.
|
||||
|
||||
static id (*orig_initStrategy2)(id, SEL, BOOL, BOOL);
|
||||
static id new_initStrategy2(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale) {
|
||||
static id (*orig_feedInit2)(id, SEL, BOOL, BOOL);
|
||||
static id new_feedInit2(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale) {
|
||||
if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) shouldDisable = YES;
|
||||
return orig_initStrategy2(self, _cmd, shouldDisable, shouldClearStale);
|
||||
return orig_feedInit2(self, _cmd, shouldDisable, shouldClearStale);
|
||||
}
|
||||
|
||||
static id (*orig_initStrategy3)(id, SEL, BOOL, BOOL, BOOL);
|
||||
static id new_initStrategy3(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale, BOOL bypassForVoiceover) {
|
||||
static id (*orig_feedInit3)(id, SEL, BOOL, BOOL, BOOL);
|
||||
static id new_feedInit3(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale, BOOL bypassForVoiceover) {
|
||||
if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) shouldDisable = YES;
|
||||
return orig_initStrategy3(self, _cmd, shouldDisable, shouldClearStale, bypassForVoiceover);
|
||||
return orig_feedInit3(self, _cmd, shouldDisable, shouldClearStale, bypassForVoiceover);
|
||||
}
|
||||
|
||||
// Carousel tap-to-play. The modern feed video cell receives single-taps via
|
||||
// this delegate callback, but the Swift implementation skips resume when the
|
||||
// cell sits inside a carousel. Force retryStartPlayback after orig.
|
||||
static void (*orig_cellDidSingleTap)(id, SEL, id, id);
|
||||
static void new_cellDidSingleTap(id self, SEL _cmd, id overlay, id gr) {
|
||||
orig_cellDidSingleTap(self, _cmd, overlay, gr);
|
||||
if (![SCIUtils getBoolPref:@"disable_feed_autoplay"]) return;
|
||||
UIView *sv = [(UIView *)self superview];
|
||||
if (!sv || !strstr(class_getName([sv class]), "Carousel")) return;
|
||||
if ([self respondsToSelector:@selector(retryStartPlayback)])
|
||||
((void(*)(id, SEL))objc_msgSend)(self, @selector(retryStartPlayback));
|
||||
}
|
||||
|
||||
static void sciHookFeedStrategy(void) {
|
||||
Class cls = objc_getClass("IGFeedPlayback.IGFeedPlaybackStrategy");
|
||||
if (!cls) return;
|
||||
SEL s2 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:);
|
||||
if ([cls instancesRespondToSelector:s2])
|
||||
MSHookMessageEx(cls, s2, (IMP)new_feedInit2, (IMP *)&orig_feedInit2);
|
||||
SEL s3 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:shouldBypassDisabledAutoplayForVoiceover:);
|
||||
if ([cls instancesRespondToSelector:s3])
|
||||
MSHookMessageEx(cls, s3, (IMP)new_feedInit3, (IMP *)&orig_feedInit3);
|
||||
}
|
||||
|
||||
static void sciHookVideoCell(void) {
|
||||
static BOOL hooked = NO;
|
||||
if (hooked) return;
|
||||
Class cls = objc_getClass("IGModernFeedVideoCell.IGModernFeedVideoCell");
|
||||
if (!cls) return;
|
||||
SEL s = @selector(videoPlayerOverlayControllerDidSingleTap:gestureRecognizer:);
|
||||
if (![cls instancesRespondToSelector:s]) return;
|
||||
MSHookMessageEx(cls, s, (IMP)new_cellDidSingleTap, (IMP *)&orig_cellDidSingleTap);
|
||||
hooked = YES;
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class cls = objc_getClass("IGFeedPlayback.IGFeedPlaybackStrategy");
|
||||
if (!cls) return;
|
||||
|
||||
SEL sel2 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:);
|
||||
if ([cls instancesRespondToSelector:sel2])
|
||||
MSHookMessageEx(cls, sel2, (IMP)new_initStrategy2, (IMP *)&orig_initStrategy2);
|
||||
|
||||
SEL sel3 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:shouldBypassDisabledAutoplayForVoiceover:);
|
||||
if ([cls instancesRespondToSelector:sel3])
|
||||
MSHookMessageEx(cls, sel3, (IMP)new_initStrategy3, (IMP *)&orig_initStrategy3);
|
||||
sciHookFeedStrategy();
|
||||
sciHookVideoCell();
|
||||
// Swift cell class can load after dylib init; retry on main runloop.
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ sciHookVideoCell(); });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Story tray long-press actions — adds "View profile picture" to the action sheet.
|
||||
// Fetches HD profile pic via /api/v1/users/{pk}/info/.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../ActionButton/SCIMediaViewer.h"
|
||||
#import "../../Networking/SCIInstagramAPI.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static __weak id sciLongPressedTrayCell = nil;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
static UIImage *sciProfileImageFromCell(id cell) {
|
||||
Ivar avIvar = class_getInstanceVariable([cell class], "_avatarView");
|
||||
if (!avIvar) return nil;
|
||||
UIView *avatarView = object_getIvar(cell, avIvar);
|
||||
if (!avatarView) return nil;
|
||||
Ivar imgIvar = class_getInstanceVariable([avatarView class], "_ownerImageView");
|
||||
if (!imgIvar) return nil;
|
||||
UIImageView *imgView = object_getIvar(avatarView, imgIvar);
|
||||
if ([imgView isKindOfClass:[UIImageView class]]) return imgView.image;
|
||||
return nil;
|
||||
}
|
||||
|
||||
static NSString *sciUsernameFromCell(id cell) {
|
||||
@try {
|
||||
Ivar mi = class_getInstanceVariable([cell class], "_model");
|
||||
if (!mi) return nil;
|
||||
id model = object_getIvar(cell, mi);
|
||||
id title = [model valueForKey:@"title"];
|
||||
if ([title isKindOfClass:[NSAttributedString class]])
|
||||
return [[(NSAttributedString *)title string] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
} @catch (NSException *e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static NSString *sciFullNameFromCell(id cell) {
|
||||
@try {
|
||||
Ivar mi = class_getInstanceVariable([cell class], "_model");
|
||||
if (!mi) return nil;
|
||||
id model = object_getIvar(cell, mi);
|
||||
id owner = [model valueForKey:@"reelOwner"];
|
||||
if (!owner) return nil;
|
||||
Ivar ui = class_getInstanceVariable([owner class], "_userReelOwner_user");
|
||||
if (!ui) return nil;
|
||||
id igUser = object_getIvar(owner, ui);
|
||||
Ivar fi = NULL;
|
||||
for (Class c = [igUser class]; c && !fi; c = class_getSuperclass(c))
|
||||
fi = class_getInstanceVariable(c, "_fieldCache");
|
||||
if (!fi) return nil;
|
||||
id fc = object_getIvar(igUser, fi);
|
||||
if (![fc isKindOfClass:[NSDictionary class]]) return nil;
|
||||
id name = [(NSDictionary *)fc objectForKey:@"full_name"];
|
||||
if ([name isKindOfClass:[NSString class]] && [(NSString *)name length] > 0) return name;
|
||||
} @catch (NSException *e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static NSString *sciCaptionFromCell(id cell) {
|
||||
NSString *username = sciUsernameFromCell(cell);
|
||||
NSString *fullName = sciFullNameFromCell(cell);
|
||||
if (username && fullName) return [NSString stringWithFormat:@"%@\n%@", username, fullName];
|
||||
return username ?: fullName;
|
||||
}
|
||||
|
||||
static NSString *sciUserPKFromCell(id cell) {
|
||||
@try {
|
||||
Ivar mi = class_getInstanceVariable([cell class], "_model");
|
||||
if (!mi) return nil;
|
||||
id model = object_getIvar(cell, mi);
|
||||
id owner = [model valueForKey:@"reelOwner"];
|
||||
if (!owner) return nil;
|
||||
Ivar ui = class_getInstanceVariable([owner class], "_userReelOwner_user");
|
||||
if (!ui) return nil;
|
||||
id igUser = object_getIvar(owner, ui);
|
||||
Ivar pi = NULL;
|
||||
for (Class c = [igUser class]; c && !pi; c = class_getSuperclass(c))
|
||||
pi = class_getInstanceVariable(c, "_pk");
|
||||
if (!pi) return nil;
|
||||
return [object_getIvar(igUser, pi) description];
|
||||
} @catch (NSException *e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Fetch HD profile pic via API, fallback to local avatar
|
||||
static void sciShowHDProfilePic(NSString *pk, NSString *caption, UIImage *fallback) {
|
||||
NSString *path = [NSString stringWithFormat:@"users/%@/info/", pk];
|
||||
[SCIInstagramAPI sendRequestWithMethod:@"GET" path:path body:nil completion:^(NSDictionary *response, NSError *error) {
|
||||
if (error || !response) {
|
||||
if (fallback) {
|
||||
NSData *d = UIImageJPEGRepresentation(fallback, 1.0);
|
||||
NSString *p = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"pfp_%@.jpg", pk]];
|
||||
[d writeToFile:p atomically:YES];
|
||||
[SCIMediaViewer showWithVideoURL:nil photoURL:[NSURL fileURLWithPath:p] caption:caption];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
NSDictionary *user = response[@"user"];
|
||||
NSString *hdURL = nil;
|
||||
|
||||
NSDictionary *hdInfo = user[@"hd_profile_pic_url_info"];
|
||||
if ([hdInfo isKindOfClass:[NSDictionary class]]) hdURL = hdInfo[@"url"];
|
||||
|
||||
if (!hdURL) {
|
||||
NSArray *versions = user[@"hd_profile_pic_versions"];
|
||||
if ([versions isKindOfClass:[NSArray class]] && versions.count > 0)
|
||||
hdURL = [versions.lastObject objectForKey:@"url"];
|
||||
}
|
||||
|
||||
if (!hdURL) hdURL = user[@"profile_pic_url"];
|
||||
|
||||
if (hdURL) {
|
||||
[SCIMediaViewer showWithVideoURL:nil photoURL:[NSURL URLWithString:hdURL] caption:caption];
|
||||
} else if (fallback) {
|
||||
NSData *d = UIImageJPEGRepresentation(fallback, 1.0);
|
||||
NSString *p = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"pfp_%@.jpg", pk]];
|
||||
[d writeToFile:p atomically:YES];
|
||||
[SCIMediaViewer showWithVideoURL:nil photoURL:[NSURL fileURLWithPath:p] caption:caption];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ── Capture long-pressed cell ──
|
||||
|
||||
static void (*orig_didLongPressCell)(id, SEL, UIGestureRecognizer *);
|
||||
static void hook_didLongPressCell(id self, SEL _cmd, UIGestureRecognizer *gesture) {
|
||||
if (gesture.state == UIGestureRecognizerStateBegan)
|
||||
sciLongPressedTrayCell = gesture.view;
|
||||
orig_didLongPressCell(self, _cmd, gesture);
|
||||
}
|
||||
|
||||
// ── Inject action into the sheet ──
|
||||
|
||||
static void (*orig_present)(id, SEL, id, BOOL, id);
|
||||
static void hook_present(id self, SEL _cmd, id vc, BOOL animated, id completion) {
|
||||
if (sciLongPressedTrayCell && [SCIUtils getBoolPref:@"story_tray_actions"]) {
|
||||
Ivar actIvar = class_getInstanceVariable([vc class], "_actions");
|
||||
NSArray *actions = actIvar ? object_getIvar(vc, actIvar) : nil;
|
||||
|
||||
if (actions) {
|
||||
id cell = sciLongPressedTrayCell;
|
||||
sciLongPressedTrayCell = nil;
|
||||
|
||||
Class actionCls = NSClassFromString(@"IGActionSheetControllerAction");
|
||||
NSString *pk = sciUserPKFromCell(cell);
|
||||
if (actionCls && pk) {
|
||||
NSString *caption = sciCaptionFromCell(cell);
|
||||
UIImage *localPic = sciProfileImageFromCell(cell);
|
||||
|
||||
typedef id (*InitFn)(id, SEL, id, id, NSInteger, id, id, id);
|
||||
void (^handler)(void) = ^{ sciShowHDProfilePic(pk, caption, localPic); };
|
||||
id action = ((InitFn)objc_msgSend)([actionCls alloc],
|
||||
@selector(initWithTitle:subtitle:style:handler:accessibilityIdentifier:accessibilityLabel:),
|
||||
@"View profile picture", nil, (NSInteger)0, handler, nil, nil);
|
||||
|
||||
if (action) {
|
||||
NSMutableArray *newActions = [actions mutableCopy];
|
||||
[newActions insertObject:action atIndex:0];
|
||||
object_setIvar(vc, actIvar, [newActions copy]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sciLongPressedTrayCell) sciLongPressedTrayCell = nil;
|
||||
orig_present(self, _cmd, vc, animated, completion);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class scCls = NSClassFromString(@"IGStorySectionController");
|
||||
if (scCls) {
|
||||
SEL sel = NSSelectorFromString(@"_didLongPressCell:");
|
||||
if (class_getInstanceMethod(scCls, sel))
|
||||
MSHookMessageEx(scCls, sel, (IMP)hook_didLongPressCell, (IMP *)&orig_didLongPressCell);
|
||||
}
|
||||
|
||||
MSHookMessageEx([UIViewController class], @selector(presentViewController:animated:completion:),
|
||||
(IMP)hook_present, (IMP *)&orig_present);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ static id new_commentCtxMenu(id self, SEL _cmd, id cv, id indexPath, CGPoint poi
|
||||
NSMutableArray *extra = [NSMutableArray array];
|
||||
|
||||
if (hasText && [SCIUtils getBoolPref:@"copy_comment"]) {
|
||||
[extra addObject:[UIAction actionWithTitle:@"Copy"
|
||||
[extra addObject:[UIAction actionWithTitle:SCILocalized(@"Copy")
|
||||
image:[UIImage systemImageNamed:@"doc.on.doc"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
@@ -68,7 +68,7 @@ static id new_commentCtxMenu(id self, SEL _cmd, id cv, id indexPath, CGPoint poi
|
||||
}
|
||||
|
||||
if (hasGif && [SCIUtils getBoolPref:@"download_gif_comment"]) {
|
||||
[extra addObject:[UIAction actionWithTitle:@"Download GIF"
|
||||
[extra addObject:[UIAction actionWithTitle:SCILocalized(@"Download GIF")
|
||||
image:[UIImage systemImageNamed:@"arrow.down.circle"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
// Notify user
|
||||
JGProgressHUD *HUD = [[JGProgressHUD alloc] init];
|
||||
HUD.textLabel.text = @"Copied text to clipboard";
|
||||
HUD.textLabel.text = SCILocalized(@"Copied text to clipboard");
|
||||
HUD.indicatorView = [[JGProgressHUDSuccessIndicatorView alloc] init];
|
||||
|
||||
[HUD showInView:topMostController().view];
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
UIColorPickerViewController *colorPickerController = [[UIColorPickerViewController alloc] init];
|
||||
|
||||
colorPickerController.delegate = (id<UIColorPickerViewControllerDelegate>)self; // cast to suppress warnings
|
||||
colorPickerController.title = @"Select color";
|
||||
colorPickerController.title = SCILocalized(@"Select color");
|
||||
colorPickerController.modalPresentationStyle = UIModalPresentationPopover;
|
||||
colorPickerController.supportsAlpha = NO;
|
||||
colorPickerController.selectedColor = self.color;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Disable feed refresh — background refresh and home tab refresh.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static BOOL sciDisableBgRefresh(void) {
|
||||
return [SCIUtils getBoolPref:@"disable_bg_refresh"];
|
||||
}
|
||||
|
||||
static BOOL sciDisableHomeRefresh(void) {
|
||||
return [SCIUtils getBoolPref:@"disable_home_refresh"];
|
||||
}
|
||||
|
||||
static BOOL sciDisableHomeScroll(void) {
|
||||
return [SCIUtils getBoolPref:@"disable_home_scroll"];
|
||||
}
|
||||
|
||||
static BOOL sciDisableReelsRefresh(void) {
|
||||
return [SCIUtils getBoolPref:@"disable_reels_tab_refresh"];
|
||||
}
|
||||
|
||||
// Returns 999999s when disabled (effectively never), -1 to keep IG's value.
|
||||
static double sciOverrideInterval(void) {
|
||||
if (sciDisableBgRefresh()) return 999999;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// MARK: - Refresh-utility class-method overrides
|
||||
// IGMainFeedRefreshUtility recomputes the intervals at runtime, ignoring the
|
||||
// init args on IGMainFeedNetworkSource — override the 4 class methods too.
|
||||
|
||||
static double (*orig_wsRefresh)(id, SEL, id, id);
|
||||
static double new_wsRefresh(id self, SEL _cmd, id ls, id store) {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : orig_wsRefresh(self, _cmd, ls, store);
|
||||
}
|
||||
|
||||
static double (*orig_wsBgRefresh)(id, SEL, id, id);
|
||||
static double new_wsBgRefresh(id self, SEL _cmd, id ls, id store) {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : orig_wsBgRefresh(self, _cmd, ls, store);
|
||||
}
|
||||
|
||||
static double (*orig_peakWsRefresh)(id, SEL, double, id, id);
|
||||
static double new_peakWsRefresh(id self, SEL _cmd, double iv, id ls, id store) {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : orig_peakWsRefresh(self, _cmd, iv, ls, store);
|
||||
}
|
||||
|
||||
static double (*orig_peakWsBgRefresh)(id, SEL, id, id);
|
||||
static double new_peakWsBgRefresh(id self, SEL _cmd, id ls, id store) {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : orig_peakWsBgRefresh(self, _cmd, ls, store);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class c = NSClassFromString(@"IGMainFeedViewModelUtility.IGMainFeedRefreshUtility");
|
||||
if (!c) return;
|
||||
Class meta = object_getClass(c);
|
||||
|
||||
SEL s1 = NSSelectorFromString(@"warmStartRefreshIntervalWithLauncherSet:feedRefreshInstructionsStore:");
|
||||
if (class_getInstanceMethod(meta, s1))
|
||||
MSHookMessageEx(meta, s1, (IMP)new_wsRefresh, (IMP *)&orig_wsRefresh);
|
||||
|
||||
SEL s2 = NSSelectorFromString(@"warmStartBackgroundRefreshIntervalWithLauncherSet:feedRefreshInstructionsStore:");
|
||||
if (class_getInstanceMethod(meta, s2))
|
||||
MSHookMessageEx(meta, s2, (IMP)new_wsBgRefresh, (IMP *)&orig_wsBgRefresh);
|
||||
|
||||
SEL s3 = NSSelectorFromString(@"onPeakWarmStartRefreshIntervalWithWarmStartFetchInterval:launcherSet:feedRefreshInstructionsStore:");
|
||||
if (class_getInstanceMethod(meta, s3))
|
||||
MSHookMessageEx(meta, s3, (IMP)new_peakWsRefresh, (IMP *)&orig_peakWsRefresh);
|
||||
|
||||
SEL s4 = NSSelectorFromString(@"onPeakWarmStartBackgroundRefreshIntervalWithLauncherSet:feedRefreshInstructionsStore:");
|
||||
if (class_getInstanceMethod(meta, s4))
|
||||
MSHookMessageEx(meta, s4, (IMP)new_peakWsBgRefresh, (IMP *)&orig_peakWsBgRefresh);
|
||||
}
|
||||
|
||||
// MARK: - Background refresh
|
||||
|
||||
%hook IGMainFeedNetworkSource
|
||||
|
||||
- (instancetype)initWithDeps:(id)a1
|
||||
posts:(id)a2
|
||||
nextMaxID:(id)a3
|
||||
initialPaginationSource:(id)a4
|
||||
contentCoordinator:(id)a5
|
||||
dataSourceSupplementaryItemsProvider:(id)a6
|
||||
disableAutomaticRefresh:(BOOL)disable
|
||||
disableSerialization:(BOOL)a8
|
||||
sessionId:(id)a9
|
||||
analyticsModule:(id)a10
|
||||
serializationSuffix:(id)a11
|
||||
disableFlashFeedTLI:(BOOL)a12
|
||||
disableFlashFeedOnColdStart:(BOOL)a13
|
||||
disableResponseDeferral:(BOOL)a14
|
||||
hidesStoriesTray:(BOOL)a15
|
||||
isSecondaryFeed:(BOOL)a16
|
||||
collectionViewBackgroundColorOverride:(id)a17
|
||||
minWarmStartFetchInterval:(double)a18
|
||||
peakMinWarmStartFetchInterval:(double)a19
|
||||
minimumWarmStartBackgroundedInterval:(double)a20
|
||||
peakMinimumWarmStartBackgroundedInterval:(double)a21
|
||||
supplementalFeedHoistedMediaID:(id)a22
|
||||
headerTitleOverride:(id)a23
|
||||
isInFollowingTab:(BOOL)a24
|
||||
useShimmerLoadingWhenNoStoriesTray:(BOOL)a25 {
|
||||
|
||||
double override = sciOverrideInterval();
|
||||
if (sciDisableBgRefresh()) disable = YES;
|
||||
if (override > 0) { a18 = override; a19 = override; a20 = override; a21 = override; }
|
||||
|
||||
return %orig(a1, a2, a3, a4, a5, a6, disable, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23, a24, a25);
|
||||
}
|
||||
|
||||
// Getter overrides for instances created before the class hooks landed.
|
||||
- (double)minWarmStartFetchInterval {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : %orig;
|
||||
}
|
||||
- (double)peakMinWarmStartFetchInterval {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : %orig;
|
||||
}
|
||||
- (double)minimumWarmStartBackgroundedInterval {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : %orig;
|
||||
}
|
||||
- (double)peakMinimumWarmStartBackgroundedInterval {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : %orig;
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// MARK: - Hot start refresh
|
||||
|
||||
%hook IGMainFeedViewController
|
||||
|
||||
- (void)hotStartRefresh {
|
||||
if (sciDisableBgRefresh()) return;
|
||||
%orig;
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// MARK: - Home tab refresh
|
||||
|
||||
%hook IGTabBarController
|
||||
|
||||
- (void)_timelineButtonPressed {
|
||||
BOOL noRefresh = sciDisableHomeRefresh();
|
||||
BOOL noScroll = sciDisableHomeScroll();
|
||||
|
||||
if (!noRefresh && !noScroll) { %orig; return; }
|
||||
|
||||
UIViewController *selected = nil;
|
||||
if ([self respondsToSelector:@selector(selectedViewController)])
|
||||
selected = [self valueForKey:@"selectedViewController"];
|
||||
|
||||
BOOL onFeedTab = NO;
|
||||
if (selected) {
|
||||
UIViewController *top = [selected isKindOfClass:[UINavigationController class]]
|
||||
? [(UINavigationController *)selected topViewController] : selected;
|
||||
onFeedTab = [NSStringFromClass([top class]) containsString:@"MainFeed"];
|
||||
}
|
||||
|
||||
if (!onFeedTab) { %orig; return; }
|
||||
if (noScroll) return;
|
||||
|
||||
// noRefresh only — scroll to top without refreshing.
|
||||
UIViewController *top = [selected isKindOfClass:[UINavigationController class]]
|
||||
? [(UINavigationController *)selected topViewController] : selected;
|
||||
|
||||
NSMutableArray *queue = [NSMutableArray arrayWithObject:top.view];
|
||||
int scanned = 0;
|
||||
while (queue.count && scanned < 30) {
|
||||
UIView *cur = queue.firstObject; [queue removeObjectAtIndex:0]; scanned++;
|
||||
if ([cur isKindOfClass:[UICollectionView class]]) {
|
||||
UIScrollView *sv = (UIScrollView *)cur;
|
||||
[sv setContentOffset:CGPointMake(0, -sv.adjustedContentInset.top) animated:YES];
|
||||
return;
|
||||
}
|
||||
for (UIView *s in cur.subviews) [queue addObject:s];
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reels tab refresh
|
||||
|
||||
- (void)_discoverVideoButtonPressed {
|
||||
if (!sciDisableReelsRefresh()) { %orig; return; }
|
||||
|
||||
UIViewController *selected = nil;
|
||||
if ([self respondsToSelector:@selector(selectedViewController)])
|
||||
selected = [self valueForKey:@"selectedViewController"];
|
||||
|
||||
BOOL onReelsTab = NO;
|
||||
if (selected) {
|
||||
UIViewController *top = [selected isKindOfClass:[UINavigationController class]]
|
||||
? [(UINavigationController *)selected topViewController] : selected;
|
||||
NSString *cls = NSStringFromClass([top class]);
|
||||
onReelsTab = [cls containsString:@"Sundial"] || [cls containsString:@"Reels"]
|
||||
|| [cls containsString:@"DiscoverVideo"];
|
||||
}
|
||||
|
||||
if (!onReelsTab) { %orig; return; }
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -0,0 +1,33 @@
|
||||
#import "../../Utils.h"
|
||||
|
||||
%hook UIImpactFeedbackGenerator
|
||||
- (void)impactOccurred {
|
||||
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig;
|
||||
}
|
||||
- (void)impactOccurredWithIntensity:(CGFloat)intensity {
|
||||
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig(intensity);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook UINotificationFeedbackGenerator
|
||||
- (void)notificationOccurred:(UINotificationFeedbackType)notificationType {
|
||||
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig(notificationType);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook UISelectionFeedbackGenerator
|
||||
- (void)selectionChanged {
|
||||
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig;
|
||||
}
|
||||
%end
|
||||
|
||||
%hook CHHapticEngine
|
||||
- (BOOL)startAndReturnError:(NSError **)outError {
|
||||
if (![SCIUtils getBoolPref:@"disable_haptics"]) {
|
||||
return %orig(outError);
|
||||
}
|
||||
else {
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,49 @@
|
||||
// Fake location — overrides CLLocationManager so any IG location read returns our coord.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <CoreLocation/CoreLocation.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
static BOOL sciFakeLocOn(void) {
|
||||
return [SCIUtils getBoolPref:@"fake_location_enabled"];
|
||||
}
|
||||
|
||||
static CLLocation *sciFakeLocation(void) {
|
||||
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
|
||||
double lat = [[d objectForKey:@"fake_location_lat"] doubleValue];
|
||||
double lon = [[d objectForKey:@"fake_location_lon"] doubleValue];
|
||||
return [[CLLocation alloc] initWithCoordinate:CLLocationCoordinate2DMake(lat, lon)
|
||||
altitude:35
|
||||
horizontalAccuracy:5
|
||||
verticalAccuracy:5
|
||||
timestamp:[NSDate date]];
|
||||
}
|
||||
|
||||
static void sciFeedFake(CLLocationManager *mgr) {
|
||||
id<CLLocationManagerDelegate> d = mgr.delegate;
|
||||
if (![d respondsToSelector:@selector(locationManager:didUpdateLocations:)]) return;
|
||||
CLLocation *loc = sciFakeLocation();
|
||||
NSArray *locs = @[ loc ];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[d locationManager:mgr didUpdateLocations:locs];
|
||||
});
|
||||
}
|
||||
|
||||
%hook CLLocationManager
|
||||
|
||||
- (CLLocation *)location {
|
||||
if (sciFakeLocOn()) return sciFakeLocation();
|
||||
return %orig;
|
||||
}
|
||||
|
||||
- (void)startUpdatingLocation {
|
||||
%orig;
|
||||
if (sciFakeLocOn()) sciFeedFake(self);
|
||||
}
|
||||
|
||||
- (void)requestLocation {
|
||||
if (sciFakeLocOn()) { sciFeedFake(self); return; }
|
||||
%orig;
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -0,0 +1,279 @@
|
||||
// Quick fake-location toggle injected into IG's Friends Map (DMs > Maps).
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../SCIChrome.h"
|
||||
#import "../../Settings/SCIFakeLocationSettingsVC.h"
|
||||
#import "../../Settings/SCIFakeLocationPickerVC.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static const NSInteger kSciMapBtnTag = 0x5C1F4B;
|
||||
static const NSInteger kSciMapHitBtnTag = 0x5C1F4C;
|
||||
|
||||
static UIViewController *sciTopMost(void) {
|
||||
UIWindow *win = nil;
|
||||
for (UIScene *sc in [UIApplication sharedApplication].connectedScenes) {
|
||||
if (![sc isKindOfClass:[UIWindowScene class]]) continue;
|
||||
for (UIWindow *w in ((UIWindowScene *)sc).windows) if (w.isKeyWindow) { win = w; break; }
|
||||
if (win) break;
|
||||
}
|
||||
UIViewController *v = win.rootViewController;
|
||||
while (v.presentedViewController) v = v.presentedViewController;
|
||||
return v;
|
||||
}
|
||||
|
||||
static void sciRefreshMapButton(UIView *mapView);
|
||||
static void sciAddMapButton(UIView *mapView);
|
||||
static void sciRemoveMapButton(UIView *mapView);
|
||||
static UIMenu *sciBuildMapMenu(void);
|
||||
|
||||
static void sciWalkMapViews(UIView *root, Class mapCls, void (^block)(UIView *)) {
|
||||
if (!root) return;
|
||||
if (mapCls && [root isKindOfClass:mapCls]) block(root);
|
||||
for (UIView *s in root.subviews) sciWalkMapViews(s, mapCls, block);
|
||||
}
|
||||
|
||||
static void sciRefreshActiveMapButton(void) {
|
||||
Class mapCls = NSClassFromString(@"IGFriendsMapCoreUI.IGFriendsMapView");
|
||||
for (UIScene *sc in [UIApplication sharedApplication].connectedScenes) {
|
||||
if (![sc isKindOfClass:[UIWindowScene class]]) continue;
|
||||
for (UIWindow *w in ((UIWindowScene *)sc).windows) {
|
||||
sciWalkMapViews(w, mapCls, ^(UIView *mv) {
|
||||
if (![SCIUtils getBoolPref:@"show_fake_location_map_button"]) {
|
||||
sciRemoveMapButton(mv);
|
||||
} else {
|
||||
sciAddMapButton(mv);
|
||||
sciRefreshMapButton(mv);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void sciOpenPickerForCurrent(void) {
|
||||
UIViewController *top = sciTopMost();
|
||||
if (!top) return;
|
||||
SCIFakeLocationPickerVC *vc = [SCIFakeLocationPickerVC new];
|
||||
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
|
||||
vc.initialCoord = CLLocationCoordinate2DMake([[d objectForKey:@"fake_location_lat"] doubleValue],
|
||||
[[d objectForKey:@"fake_location_lon"] doubleValue]);
|
||||
vc.titleText = SCILocalized(@"Set location");
|
||||
vc.onPick = ^(double lat, double lon, NSString *name) {
|
||||
NSUserDefaults *u = [NSUserDefaults standardUserDefaults];
|
||||
[u setObject:@(lat) forKey:@"fake_location_lat"];
|
||||
[u setObject:@(lon) forKey:@"fake_location_lon"];
|
||||
[u setObject:(name ?: @"") forKey:@"fake_location_name"];
|
||||
if (![u boolForKey:@"fake_location_enabled"]) [u setBool:YES forKey:@"fake_location_enabled"];
|
||||
sciRefreshActiveMapButton();
|
||||
};
|
||||
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
|
||||
nav.modalPresentationStyle = UIModalPresentationPageSheet;
|
||||
[top presentViewController:nav animated:YES completion:nil];
|
||||
}
|
||||
|
||||
static void sciOpenPickerForNewPreset(void) {
|
||||
UIViewController *top = sciTopMost();
|
||||
if (!top) return;
|
||||
SCIFakeLocationPickerVC *vc = [SCIFakeLocationPickerVC new];
|
||||
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
|
||||
vc.initialCoord = CLLocationCoordinate2DMake([[d objectForKey:@"fake_location_lat"] doubleValue],
|
||||
[[d objectForKey:@"fake_location_lon"] doubleValue]);
|
||||
vc.titleText = SCILocalized(@"Add preset");
|
||||
vc.onPick = ^(double lat, double lon, NSString *name) {
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Save preset")
|
||||
message:nil
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
[alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { tf.placeholder = SCILocalized(@"Name"); tf.text = name; }];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Save") style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) {
|
||||
NSString *n = alert.textFields.firstObject.text.length ? alert.textFields.firstObject.text : name;
|
||||
NSUserDefaults *u = [NSUserDefaults standardUserDefaults];
|
||||
NSArray *raw = [u objectForKey:@"fake_location_presets"];
|
||||
NSMutableArray *presets = [raw isKindOfClass:[NSArray class]] ? [raw mutableCopy] : [NSMutableArray array];
|
||||
[presets addObject:@{@"name": n ?: @"", @"lat": @(lat), @"lon": @(lon)}];
|
||||
[u setObject:presets forKey:@"fake_location_presets"];
|
||||
sciRefreshActiveMapButton();
|
||||
}]];
|
||||
[sciTopMost() presentViewController:alert animated:YES completion:nil];
|
||||
};
|
||||
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
|
||||
nav.modalPresentationStyle = UIModalPresentationPageSheet;
|
||||
[top presentViewController:nav animated:YES completion:nil];
|
||||
}
|
||||
|
||||
static UIMenu *sciBuildMapMenu(void) {
|
||||
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
|
||||
BOOL enabled = [d boolForKey:@"fake_location_enabled"];
|
||||
NSString *name = [d objectForKey:@"fake_location_name"] ?: @"(unset)";
|
||||
|
||||
// Header section: current location (disabled), enable/disable, change location
|
||||
UIAction *header = [UIAction actionWithTitle:[NSString stringWithFormat:SCILocalized(@"Current: %@"), name]
|
||||
image:[UIImage systemImageNamed:@"mappin.and.ellipse"]
|
||||
identifier:nil handler:^(__unused UIAction *a) {}];
|
||||
header.attributes = UIMenuElementAttributesDisabled;
|
||||
|
||||
UIAction *toggle = [UIAction actionWithTitle:enabled ? SCILocalized(@"Disable") : SCILocalized(@"Enable")
|
||||
image:[UIImage systemImageNamed:enabled ? @"location.slash.fill" : @"location.fill"]
|
||||
identifier:nil
|
||||
handler:^(__unused UIAction *a) {
|
||||
[d setBool:!enabled forKey:@"fake_location_enabled"];
|
||||
sciRefreshActiveMapButton();
|
||||
}];
|
||||
if (enabled) toggle.attributes = UIMenuElementAttributesDestructive;
|
||||
|
||||
UIAction *change = [UIAction actionWithTitle:SCILocalized(@"Change location")
|
||||
image:[UIImage systemImageNamed:@"map"]
|
||||
identifier:nil
|
||||
handler:^(__unused UIAction *a) { sciOpenPickerForCurrent(); }];
|
||||
|
||||
UIMenu *headerSection = [UIMenu menuWithTitle:@"" image:nil identifier:nil
|
||||
options:UIMenuOptionsDisplayInline children:@[header, toggle, change]];
|
||||
|
||||
// Presets + Add
|
||||
NSMutableArray<UIMenuElement *> *presetItems = [NSMutableArray array];
|
||||
NSArray *presets = [d objectForKey:@"fake_location_presets"];
|
||||
if ([presets isKindOfClass:[NSArray class]]) {
|
||||
for (NSDictionary *p in presets) {
|
||||
if (![p isKindOfClass:[NSDictionary class]]) continue;
|
||||
NSString *pname = p[@"name"] ?: @"Preset";
|
||||
BOOL active = [p[@"name"] isEqualToString:name];
|
||||
UIAction *act = [UIAction actionWithTitle:pname
|
||||
image:[UIImage systemImageNamed:@"mappin.circle.fill"]
|
||||
identifier:nil
|
||||
handler:^(__unused UIAction *x) {
|
||||
[d setObject:p[@"lat"] forKey:@"fake_location_lat"];
|
||||
[d setObject:p[@"lon"] forKey:@"fake_location_lon"];
|
||||
[d setObject:p[@"name"] ?: @"" forKey:@"fake_location_name"];
|
||||
if (![d boolForKey:@"fake_location_enabled"]) [d setBool:YES forKey:@"fake_location_enabled"];
|
||||
sciRefreshActiveMapButton();
|
||||
}];
|
||||
if (active) act.state = UIMenuElementStateOn;
|
||||
[presetItems addObject:act];
|
||||
}
|
||||
}
|
||||
[presetItems addObject:[UIAction actionWithTitle:SCILocalized(@"Add location")
|
||||
image:[UIImage systemImageNamed:@"plus.circle.fill"]
|
||||
identifier:nil
|
||||
handler:^(__unused UIAction *x) { sciOpenPickerForNewPreset(); }]];
|
||||
UIMenu *presetSection = [UIMenu menuWithTitle:SCILocalized(@"Saved locations") image:nil identifier:nil
|
||||
options:UIMenuOptionsDisplayInline children:presetItems];
|
||||
|
||||
// Settings
|
||||
UIAction *openSettings = [UIAction actionWithTitle:SCILocalized(@"Settings…")
|
||||
image:[UIImage systemImageNamed:@"gearshape.fill"]
|
||||
identifier:nil
|
||||
handler:^(__unused UIAction *x) {
|
||||
UIViewController *top = sciTopMost();
|
||||
if (!top) return;
|
||||
SCIFakeLocationSettingsVC *vc = [SCIFakeLocationSettingsVC new];
|
||||
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
|
||||
nav.modalPresentationStyle = UIModalPresentationFormSheet;
|
||||
[top presentViewController:nav animated:YES completion:nil];
|
||||
}];
|
||||
UIMenu *settingsSection = [UIMenu menuWithTitle:@"" image:nil identifier:nil
|
||||
options:UIMenuOptionsDisplayInline children:@[openSettings]];
|
||||
|
||||
return [UIMenu menuWithTitle:SCILocalized(@"Fake location") image:nil identifier:nil options:0
|
||||
children:@[headerSection, presetSection, settingsSection]];
|
||||
}
|
||||
|
||||
static void sciRemoveMapButton(UIView *mapView) {
|
||||
UIView *btn = [mapView viewWithTag:kSciMapBtnTag];
|
||||
if (btn) [btn removeFromSuperview];
|
||||
UIView *hit = [mapView viewWithTag:kSciMapHitBtnTag];
|
||||
if (hit) [hit removeFromSuperview];
|
||||
}
|
||||
|
||||
static void sciAddMapButton(UIView *mapView) {
|
||||
if (!mapView) return;
|
||||
if (![SCIUtils getBoolPref:@"show_fake_location_map_button"]) { sciRemoveMapButton(mapView); return; }
|
||||
if ([mapView viewWithTag:kSciMapBtnTag]) return;
|
||||
|
||||
// Visible chrome — static, never absorbed into the menu platter animation.
|
||||
BOOL on = [SCIUtils getBoolPref:@"fake_location_enabled"];
|
||||
SCIChromeButton *chrome = [[SCIChromeButton alloc] initWithSymbol:on ? @"location.fill" : @"location.slash"
|
||||
pointSize:18
|
||||
diameter:48];
|
||||
chrome.tag = kSciMapBtnTag;
|
||||
chrome.bubbleColor = [UIColor secondarySystemBackgroundColor];
|
||||
chrome.iconTint = on ? [UIColor systemGreenColor] : [UIColor labelColor];
|
||||
chrome.layer.shadowColor = [UIColor blackColor].CGColor;
|
||||
chrome.layer.shadowOpacity = 0.18;
|
||||
chrome.layer.shadowRadius = 5;
|
||||
chrome.layer.shadowOffset = CGSizeMake(0, 2);
|
||||
chrome.userInteractionEnabled = NO;
|
||||
[mapView addSubview:chrome];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[chrome.leadingAnchor constraintEqualToAnchor:mapView.leadingAnchor constant:16],
|
||||
[chrome.topAnchor constraintEqualToAnchor:mapView.safeAreaLayoutGuide.topAnchor constant:78],
|
||||
[chrome.widthAnchor constraintEqualToConstant:48],
|
||||
[chrome.heightAnchor constraintEqualToConstant:48],
|
||||
]];
|
||||
|
||||
// Invisible hit target owns the menu; visible chrome below stays put
|
||||
// when UIKit absorbs the hit into the menu platter on dismiss.
|
||||
UIButton *hit = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
hit.tag = kSciMapHitBtnTag;
|
||||
hit.backgroundColor = [UIColor clearColor];
|
||||
hit.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
hit.showsMenuAsPrimaryAction = YES;
|
||||
hit.menu = sciBuildMapMenu();
|
||||
[hit addAction:[UIAction actionWithHandler:^(__unused UIAction *a) {
|
||||
hit.menu = sciBuildMapMenu();
|
||||
}] forControlEvents:UIControlEventMenuActionTriggered];
|
||||
[mapView addSubview:hit];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[hit.leadingAnchor constraintEqualToAnchor:chrome.leadingAnchor],
|
||||
[hit.trailingAnchor constraintEqualToAnchor:chrome.trailingAnchor],
|
||||
[hit.topAnchor constraintEqualToAnchor:chrome.topAnchor],
|
||||
[hit.bottomAnchor constraintEqualToAnchor:chrome.bottomAnchor],
|
||||
]];
|
||||
}
|
||||
|
||||
static void sciRefreshMapButton(UIView *mapView) {
|
||||
SCIChromeButton *btn = (SCIChromeButton *)[mapView viewWithTag:kSciMapBtnTag];
|
||||
if (![btn isKindOfClass:[SCIChromeButton class]]) return;
|
||||
BOOL on = [SCIUtils getBoolPref:@"fake_location_enabled"];
|
||||
btn.symbolName = on ? @"location.fill" : @"location.slash";
|
||||
btn.iconTint = on ? [UIColor systemGreenColor] : [UIColor labelColor];
|
||||
// Don't touch btn.menu here — reassigning mid-dismiss flickers the button.
|
||||
// UIControlEventMenuActionTriggered rebuilds on next open.
|
||||
}
|
||||
|
||||
static void (*orig_mapLayout)(UIView *, SEL);
|
||||
static void new_mapLayout(UIView *self, SEL _cmd) {
|
||||
orig_mapLayout(self, _cmd);
|
||||
if (![SCIUtils getBoolPref:@"show_fake_location_map_button"]) {
|
||||
sciRemoveMapButton(self);
|
||||
return;
|
||||
}
|
||||
sciAddMapButton(self);
|
||||
sciRefreshMapButton(self);
|
||||
UIView *btn = [self viewWithTag:kSciMapBtnTag];
|
||||
if (btn) [self bringSubviewToFront:btn];
|
||||
}
|
||||
|
||||
static void sciInstallMapHooks(void) {
|
||||
static BOOL installed = NO;
|
||||
if (installed) return;
|
||||
Class c = NSClassFromString(@"IGFriendsMapCoreUI.IGFriendsMapView");
|
||||
if (!c) return;
|
||||
installed = YES;
|
||||
SEL sel = @selector(layoutSubviews);
|
||||
if (class_getInstanceMethod(c, sel))
|
||||
MSHookMessageEx(c, sel, (IMP)new_mapLayout, (IMP *)&orig_mapLayout);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
sciInstallMapHooks();
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
sciInstallMapHooks();
|
||||
});
|
||||
[[NSNotificationCenter defaultCenter] addObserverForName:@"SCIFakeLocationMapBtnPrefChanged"
|
||||
object:nil
|
||||
queue:[NSOperationQueue mainQueue]
|
||||
usingBlock:^(__unused NSNotification *n) {
|
||||
sciRefreshActiveMapButton();
|
||||
}];
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Date format hooks — replace IG's relative timestamps with a custom format.
|
||||
// Each NSDate formatter selector is independently toggleable via prefs
|
||||
// (date_fmt_<name>) so users can apply the format surface-by-surface.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "SCIDateFormatEntries.h"
|
||||
#import <substrate.h>
|
||||
|
||||
static NSDictionary *sciDateFormats(BOOL sec) {
|
||||
return sec ? @{
|
||||
@"short": @"MMM d",
|
||||
@"medium": @"MMM d, yyyy",
|
||||
@"full": @"MMM d, yyyy 'at' h:mm:ss a",
|
||||
@"time_12": @"MMM d 'at' h:mm:ss a",
|
||||
@"time_24": @"MMM d 'at' HH:mm:ss",
|
||||
@"dd_mmm": @"dd-MMM-yyyy 'at' h:mm:ss a",
|
||||
@"day_slash": @"dd/MM/yyyy h:mm:ss a",
|
||||
@"month_slash": @"MM/dd/yyyy h:mm:ss a",
|
||||
@"euro": @"dd.MM.yyyy HH:mm:ss",
|
||||
@"iso": @"yyyy-MM-dd",
|
||||
@"iso_time": @"yyyy-MM-dd HH:mm:ss",
|
||||
} : @{
|
||||
@"short": @"MMM d",
|
||||
@"medium": @"MMM d, yyyy",
|
||||
@"full": @"MMM d, yyyy 'at' h:mm a",
|
||||
@"time_12": @"MMM d 'at' h:mm a",
|
||||
@"time_24": @"MMM d 'at' HH:mm",
|
||||
@"dd_mmm": @"dd-MMM-yyyy 'at' h:mm a",
|
||||
@"day_slash": @"dd/MM/yyyy h:mm a",
|
||||
@"month_slash": @"MM/dd/yyyy h:mm a",
|
||||
@"euro": @"dd.MM.yyyy HH:mm",
|
||||
@"iso": @"yyyy-MM-dd",
|
||||
@"iso_time": @"yyyy-MM-dd HH:mm",
|
||||
};
|
||||
}
|
||||
|
||||
static NSString *sciFormat(NSDate *date) {
|
||||
NSString *fmt = [SCIUtils getStringPref:@"feed_date_format"];
|
||||
if (!fmt.length || [fmt isEqualToString:@"default"]) return nil;
|
||||
BOOL sec = [[NSUserDefaults standardUserDefaults] boolForKey:@"feed_date_show_seconds"];
|
||||
NSString *pattern = sciDateFormats(sec)[fmt];
|
||||
if (!pattern) return nil;
|
||||
static NSDateFormatter *df = nil;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ df = [NSDateFormatter new]; });
|
||||
df.dateFormat = pattern;
|
||||
return [df stringFromDate:date];
|
||||
}
|
||||
|
||||
// Per-arity hook generators. When the entry's pref is on, return the custom
|
||||
// format; otherwise forward to orig with the original arguments.
|
||||
|
||||
#define SCI_HOOK0(NAME, SEL_, LABEL, PREF) \
|
||||
static NSString *(*orig_##NAME)(NSDate *, SEL); \
|
||||
static NSString *hook_##NAME(NSDate *self, SEL _cmd) { \
|
||||
if ([SCIUtils getBoolPref:@PREF]) { \
|
||||
NSString *r = sciFormat(self); \
|
||||
if (r) return r; \
|
||||
} \
|
||||
return orig_##NAME(self, _cmd); \
|
||||
}
|
||||
|
||||
#define SCI_HOOK1(NAME, SEL_, LABEL, PREF) \
|
||||
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger); \
|
||||
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1) { \
|
||||
if ([SCIUtils getBoolPref:@PREF]) { \
|
||||
NSString *r = sciFormat(self); \
|
||||
if (r) return r; \
|
||||
} \
|
||||
return orig_##NAME(self, _cmd, a1); \
|
||||
}
|
||||
|
||||
#define SCI_HOOK2(NAME, SEL_, LABEL, PREF) \
|
||||
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger, NSInteger); \
|
||||
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1, NSInteger a2) { \
|
||||
if ([SCIUtils getBoolPref:@PREF]) { \
|
||||
NSString *r = sciFormat(self); \
|
||||
if (r) return r; \
|
||||
} \
|
||||
return orig_##NAME(self, _cmd, a1, a2); \
|
||||
}
|
||||
|
||||
#define SCI_HOOK3(NAME, SEL_, LABEL, PREF) \
|
||||
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger, NSInteger, NSInteger); \
|
||||
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1, NSInteger a2, NSInteger a3) { \
|
||||
if ([SCIUtils getBoolPref:@PREF]) { \
|
||||
NSString *r = sciFormat(self); \
|
||||
if (r) return r; \
|
||||
} \
|
||||
return orig_##NAME(self, _cmd, a1, a2, a3); \
|
||||
}
|
||||
|
||||
#define SCI_HOOK4(NAME, SEL_, LABEL, PREF) \
|
||||
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger, NSInteger, NSInteger, NSInteger); \
|
||||
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1, NSInteger a2, NSInteger a3, NSInteger a4) { \
|
||||
if ([SCIUtils getBoolPref:@PREF]) { \
|
||||
NSString *r = sciFormat(self); \
|
||||
if (r) return r; \
|
||||
} \
|
||||
return orig_##NAME(self, _cmd, a1, a2, a3, a4); \
|
||||
}
|
||||
|
||||
#define SCI_EMIT_HOOK(NAME, SEL_, LABEL, ARITY, PREF) SCI_HOOK##ARITY(NAME, SEL_, LABEL, PREF)
|
||||
SCI_DATE_FORMAT_ENTRIES(SCI_EMIT_HOOK)
|
||||
|
||||
#define SCI_INSTALL_HOOK(NAME, SEL_, LABEL, ARITY, PREF) do { \
|
||||
SEL s = sel_registerName(SEL_); \
|
||||
if ([[NSDate class] instancesRespondToSelector:s]) \
|
||||
MSHookMessageEx([NSDate class], s, (IMP)hook_##NAME, (IMP *)&orig_##NAME); \
|
||||
} while (0);
|
||||
|
||||
%ctor {
|
||||
SCI_DATE_FORMAT_ENTRIES(SCI_INSTALL_HOOK)
|
||||
}
|
||||
@@ -1,31 +1,172 @@
|
||||
// Explore tab hide toggles.
|
||||
// hide_explore_grid → posts grid + shimmer loader
|
||||
// hide_trending_searches → category chip bar + algo button on the right
|
||||
//
|
||||
// Grid revealing rules: tapping a chip or focusing the search bar counts as
|
||||
// engagement and unhides the grid until the user leaves the Explore tab.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
%hook IGExploreGridViewController
|
||||
- (void)viewDidLoad {
|
||||
if ([SCIUtils getBoolPref:@"hide_explore_grid"]) {
|
||||
NSLog(@"[SCInsta] Hiding explore grid");
|
||||
static BOOL sciHideGrid(void) { return [SCIUtils getBoolPref:@"hide_explore_grid"]; }
|
||||
static BOOL sciHideSearch(void) { return [SCIUtils getBoolPref:@"hide_trending_searches"]; }
|
||||
|
||||
[[self view] removeFromSuperview];
|
||||
static __weak UIViewController *gActiveExploreVC = nil;
|
||||
static BOOL gSearchFocused = NO;
|
||||
static BOOL gUserEngaged = NO;
|
||||
|
||||
return;
|
||||
// MARK: - Hide helpers
|
||||
|
||||
// Alpha + userInteraction instead of .hidden keeps IG's data fetch and the
|
||||
// shimmer animation alive, so toggling the pref back on shows fresh content
|
||||
// instantly without a restart.
|
||||
static void sciSetViewVisuallyHidden(UIView *v, BOOL hidden) {
|
||||
if (!v) return;
|
||||
v.alpha = hidden ? 0.0 : 1.0;
|
||||
v.userInteractionEnabled = !hidden;
|
||||
}
|
||||
|
||||
static void sciSetIvarViewHidden(id host, const char *name, BOOL hidden) {
|
||||
Ivar iv = class_getInstanceVariable([host class], name);
|
||||
if (!iv) return;
|
||||
@try {
|
||||
UIView *v = object_getIvar(host, iv);
|
||||
if ([v isKindOfClass:[UIView class]]) sciSetViewVisuallyHidden(v, hidden);
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
|
||||
static void sciApplyExploreHide(id vc) {
|
||||
// Chips stay visible while search is focused (they act as filters then).
|
||||
BOOL hideChips = sciHideSearch() && !gSearchFocused;
|
||||
sciSetIvarViewHidden(vc, "_nidoChipBar", hideChips);
|
||||
|
||||
// Force re-layout so pref flips reflect on re-entry.
|
||||
Ivar stvIvar = class_getInstanceVariable([vc class], "_searchTitleView");
|
||||
if (stvIvar) {
|
||||
@try {
|
||||
UIView *tv = object_getIvar(vc, stvIvar);
|
||||
if ([tv isKindOfClass:[UIView class]]) {
|
||||
[tv setNeedsLayout];
|
||||
[tv layoutIfNeeded];
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
|
||||
return %orig;
|
||||
|
||||
// Grid reveals on chip tap or search focus.
|
||||
BOOL hideGrid = sciHideGrid() && !gUserEngaged && !gSearchFocused;
|
||||
sciSetIvarViewHidden(vc, "_shimmeringGridView", hideGrid);
|
||||
|
||||
Ivar gvcIvar = class_getInstanceVariable([vc class], "_gridViewController");
|
||||
if (!gvcIvar) return;
|
||||
@try {
|
||||
UIViewController *grid = object_getIvar(vc, gvcIvar);
|
||||
if (![grid isKindOfClass:[UIViewController class]] || !grid.isViewLoaded) return;
|
||||
sciSetViewVisuallyHidden(grid.view, hideGrid);
|
||||
Ivar cvIvar = class_getInstanceVariable([grid class], "_collectionView");
|
||||
if (cvIvar) {
|
||||
UIView *cv = object_getIvar(grid, cvIvar);
|
||||
if ([cv isKindOfClass:[UIView class]]) sciSetViewVisuallyHidden(cv, hideGrid);
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
|
||||
// Algo button vs Cancel: both are IGTapButton siblings of the search bar.
|
||||
// Cancel has a UIButtonLabel (the "Cancel" text); the algo button is square
|
||||
// with just an icon child.
|
||||
static BOOL sciIsAlgoButton(UIView *btn) {
|
||||
if (btn.bounds.size.width != btn.bounds.size.height) return NO;
|
||||
for (UIView *sub in btn.subviews) {
|
||||
if ([sub isKindOfClass:[UILabel class]]) return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
// MARK: - VC hooks
|
||||
|
||||
%group HideExploreGroup
|
||||
|
||||
%hook IGExploreViewController
|
||||
- (void)viewDidLayoutSubviews {
|
||||
%orig;
|
||||
gActiveExploreVC = self;
|
||||
sciApplyExploreHide(self);
|
||||
}
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
%orig;
|
||||
gActiveExploreVC = self;
|
||||
sciApplyExploreHide(self);
|
||||
}
|
||||
- (void)viewDidDisappear:(BOOL)animated {
|
||||
%orig;
|
||||
gUserEngaged = NO;
|
||||
gSearchFocused = NO;
|
||||
}
|
||||
- (void)exploreChipBarView:(id)bar didSelectChipAtIndex:(NSInteger)idx {
|
||||
%orig;
|
||||
gUserEngaged = YES;
|
||||
sciApplyExploreHide(self);
|
||||
[self.view setNeedsLayout];
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGExploreViewController
|
||||
- (void)viewDidLoad {
|
||||
%hook IGAnimatablePlaceholderTextField
|
||||
- (BOOL)becomeFirstResponder {
|
||||
BOOL r = %orig;
|
||||
gSearchFocused = YES;
|
||||
if (gActiveExploreVC) {
|
||||
sciApplyExploreHide(gActiveExploreVC);
|
||||
[gActiveExploreVC.view setNeedsLayout];
|
||||
}
|
||||
return r;
|
||||
}
|
||||
- (BOOL)resignFirstResponder {
|
||||
BOOL r = %orig;
|
||||
gSearchFocused = NO;
|
||||
if (gActiveExploreVC) {
|
||||
sciApplyExploreHide(gActiveExploreVC);
|
||||
[gActiveExploreVC.view setNeedsLayout];
|
||||
}
|
||||
return r;
|
||||
}
|
||||
%end
|
||||
|
||||
// Hook the search title view's own layout — catches every relayout at the
|
||||
// source, so hiding the algo button + stretching the bar has no lagged frame.
|
||||
%hook IGExploreSearchTitleView
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
BOOL hide = sciHideSearch();
|
||||
Class tapBtnCls = NSClassFromString(@"IGTapButton");
|
||||
Class dotCls = NSClassFromString(@"IGDSDotView");
|
||||
Class searchCls = NSClassFromString(@"IGSearchBar");
|
||||
|
||||
if ([SCIUtils getBoolPref:@"hide_explore_grid"]) {
|
||||
NSLog(@"[SCInsta] Hiding explore grid");
|
||||
|
||||
IGShimmeringGridView *shimmeringGridView = MSHookIvar<IGShimmeringGridView *>(self, "_shimmeringGridView");
|
||||
if (shimmeringGridView != nil) {
|
||||
[shimmeringGridView removeFromSuperview];
|
||||
UIView *searchBar = nil;
|
||||
for (UIView *sub in self.subviews) {
|
||||
if (searchCls && [sub isKindOfClass:searchCls]) {
|
||||
searchBar = sub;
|
||||
} else if (tapBtnCls && [sub isKindOfClass:tapBtnCls] && sciIsAlgoButton(sub)) {
|
||||
sub.hidden = hide;
|
||||
} else if (dotCls && [sub isKindOfClass:dotCls]) {
|
||||
sub.hidden = hide;
|
||||
}
|
||||
}
|
||||
if (searchBar && hide) {
|
||||
CGFloat target = self.bounds.size.width;
|
||||
if (searchBar.frame.size.width != target) {
|
||||
CGRect f = searchBar.frame;
|
||||
f.size.width = target;
|
||||
searchBar.frame = f;
|
||||
}
|
||||
}
|
||||
}
|
||||
%end
|
||||
%end
|
||||
|
||||
%end // HideExploreGroup
|
||||
|
||||
%ctor {
|
||||
if ([SCIUtils getBoolPref:@"hide_explore_grid"] ||
|
||||
[SCIUtils getBoolPref:@"hide_trending_searches"]) {
|
||||
%init(HideExploreGroup);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,22 +135,35 @@
|
||||
// Write with meta ai in message composer
|
||||
%hook IGDirectComposer
|
||||
- (id)initWithLayoutSpecProvider:(id)arg1
|
||||
userLauncherSetProviding:(id)arg2
|
||||
userSession:(id)arg2
|
||||
userLauncherSet:(id)arg3
|
||||
config:(IGDirectComposerConfig *)config
|
||||
style:(id)arg4
|
||||
text:(id)arg5
|
||||
style:(id)arg5
|
||||
text:(id)arg6
|
||||
{
|
||||
return %orig(arg1, arg2, [self patchConfig:config], arg4, arg5);
|
||||
return %orig(arg1, arg2, arg3, [self patchConfig:config], arg5, arg6);
|
||||
}
|
||||
|
||||
- (id)initWithLayoutSpecProvider:(id)arg1
|
||||
userLauncherSetProviding:(id)arg2
|
||||
userSession:(id)arg2
|
||||
userLauncherSet:(id)arg3
|
||||
config:(IGDirectComposerConfig *)config
|
||||
style:(id)arg4
|
||||
text:(id)arg5
|
||||
shouldUpdateModeLater:(BOOL)arg6
|
||||
style:(id)arg5
|
||||
text:(id)arg6
|
||||
shouldUpdateModeLater:(BOOL)arg7
|
||||
{
|
||||
return %orig(arg1, arg2, [self patchConfig:config], arg4, arg5, arg6);
|
||||
return %orig(arg1, arg2, arg3, [self patchConfig:config], arg5, arg6, arg7);
|
||||
}
|
||||
|
||||
- (id)_initializeWithLayoutSpecProvider:(id)arg1
|
||||
userSession:(id)arg2
|
||||
userLauncherSet:(id)arg3
|
||||
config:(IGDirectComposerConfig *)config
|
||||
style:(id)arg5
|
||||
text:(id)arg6
|
||||
shouldUpdateModeLater:(BOOL)arg7
|
||||
{
|
||||
return %orig(arg1, arg2, arg3, [self patchConfig:config], arg5, arg6, arg7);
|
||||
}
|
||||
|
||||
- (void)setConfig:(IGDirectComposerConfig *)config {
|
||||
@@ -178,6 +191,20 @@
|
||||
}
|
||||
%end
|
||||
|
||||
// Demangled name: IGAIRewrite.IGAIRewriteStoryRepliesPresenter
|
||||
%hook _TtC11IGAIRewrite32IGAIRewriteStoryRepliesPresenter
|
||||
- (BOOL)shouldShowAIRewriteButton:(id)arg1 input:(id)arg2 {
|
||||
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
|
||||
NSLog(@"[SCInsta] Hiding meta ai: disable ai rewrite story reply presenter");
|
||||
|
||||
return NO;
|
||||
}
|
||||
|
||||
return %orig(arg1, arg2);
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// Direct sticker tray picker view
|
||||
%hook IGStickerTrayListAdapterDataSource
|
||||
- (id)objectsForListAdapter:(id)arg1 {
|
||||
@@ -346,6 +373,24 @@
|
||||
// Reels/Sundial
|
||||
|
||||
// Suggested AI searches in comment section
|
||||
%hook IGCommentConfig
|
||||
- (id)initWithUserSession:(id)session
|
||||
commentThreadConfiguration:(IGCommentThreadConfiguration *)threadConfig
|
||||
sponsoredSupportConfiguration:(id)supportConfig
|
||||
CTAPresenterContext:(id)context
|
||||
replyText:(id)text
|
||||
loggingDelegate:(id)loggingDelegate
|
||||
presentingViewController:(id)vc
|
||||
childCommentThreadDelegate:(id)threadDelegate
|
||||
{
|
||||
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
|
||||
[threadConfig setValue:@(YES) forKey:@"disableMetaAICarousel"];
|
||||
}
|
||||
return %orig(session, threadConfig, supportConfig, context, text, loggingDelegate, vc, threadDelegate);
|
||||
}
|
||||
%end
|
||||
|
||||
// Suggested AI searches in comment section (workaround if setting comment thread config fails)
|
||||
%hook IGCommentThreadAICarousel
|
||||
- (id)initWithLauncherSet:(id)arg1 hasSearchPrefix:(BOOL)arg2 {
|
||||
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
|
||||
@@ -383,7 +428,7 @@
|
||||
NSLog(@"[SCInsta] Hiding meta ai: ai images add to story suggestion");
|
||||
|
||||
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
|
||||
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"NOT (SELF IN %@)", @[ @(10), @(11) ]];
|
||||
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"NOT (SELF IN %@)", @[ @(9), @(10), @(11) ]];
|
||||
newTools = [tools filteredArrayUsingPredicate:predicate];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#import "../../Utils.h"
|
||||
|
||||
%hook IGSundialViewerVerticalUFI
|
||||
- (void)setNumLikes:(NSInteger)num {
|
||||
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
|
||||
}
|
||||
- (void)setNumReshares:(NSInteger)num {
|
||||
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
|
||||
}
|
||||
- (void)setNumComments:(NSInteger)num {
|
||||
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
|
||||
}
|
||||
- (void)setNumReposts:(NSInteger)num {
|
||||
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
|
||||
}
|
||||
- (void)setNumSaves:(NSInteger)num {
|
||||
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGUFIButtonWithCountsView
|
||||
- (void)setCountString:(id)string showButton:(BOOL)showButton {
|
||||
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? @"" : string, showButton);
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,77 @@
|
||||
// Hide suggested stories from the feed tray. The adapter hook is shared
|
||||
// with profile highlights, so we key off diffIdentifier: only suggested
|
||||
// items use a 32-char hex UUID (real users use numeric PKs, highlights use
|
||||
// "highlight:<pk>"). Default-keep on anything ambiguous.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static BOOL sciIsHexUUIDString(NSString *s) {
|
||||
if (s.length != 32) return NO;
|
||||
static NSCharacterSet *nonHex;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
nonHex = [[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdef"] invertedSet];
|
||||
});
|
||||
return [s rangeOfCharacterFromSet:nonHex].location == NSNotFound;
|
||||
}
|
||||
|
||||
static BOOL sciIsSuggestedTrayItem(id obj) {
|
||||
@try {
|
||||
if (![NSStringFromClass([obj class]) isEqualToString:@"IGStoryTrayViewModel"]) return NO;
|
||||
if ([[obj valueForKey:@"isCurrentUserReel"] boolValue]) return NO;
|
||||
|
||||
NSString *diffId = nil;
|
||||
@try { diffId = [[obj performSelector:@selector(diffIdentifier)] description]; } @catch (...) {}
|
||||
if (!sciIsHexUUIDString(diffId)) return NO;
|
||||
|
||||
id owner = [obj valueForKey:@"reelOwner"];
|
||||
if (!owner) return NO;
|
||||
Ivar userIvar = class_getInstanceVariable([owner class], "_userReelOwner_user");
|
||||
if (!userIvar) return NO;
|
||||
id igUser = object_getIvar(owner, userIvar);
|
||||
if (!igUser) return NO;
|
||||
|
||||
Ivar fcIvar = NULL;
|
||||
for (Class c = [igUser class]; c && !fcIvar; c = class_getSuperclass(c))
|
||||
fcIvar = class_getInstanceVariable(c, "_fieldCache");
|
||||
if (!fcIvar) return NO;
|
||||
id fc = object_getIvar(igUser, fcIvar);
|
||||
if (![fc isKindOfClass:[NSDictionary class]]) return NO;
|
||||
|
||||
id fs = [(NSDictionary *)fc objectForKey:@"friendship_status"];
|
||||
if (!fs) return NO;
|
||||
return ![[fs valueForKey:@"following"] boolValue];
|
||||
} @catch (__unused NSException *e) {
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
|
||||
static NSArray *(*orig_objectsForListAdapter)(id, SEL, id);
|
||||
static NSArray *hook_objectsForListAdapter(id self, SEL _cmd, id adapter) {
|
||||
NSArray *objects = orig_objectsForListAdapter(self, _cmd, adapter);
|
||||
if (![SCIUtils getBoolPref:@"hide_suggested_stories"]) return objects;
|
||||
|
||||
BOOL anySuggested = NO;
|
||||
for (id obj in objects) {
|
||||
if (sciIsSuggestedTrayItem(obj)) { anySuggested = YES; break; }
|
||||
}
|
||||
if (!anySuggested) return objects;
|
||||
|
||||
NSMutableArray *filtered = [NSMutableArray arrayWithCapacity:objects.count];
|
||||
for (id obj in objects) {
|
||||
if (!sciIsSuggestedTrayItem(obj)) [filtered addObject:obj];
|
||||
}
|
||||
return [filtered copy];
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class cls = NSClassFromString(@"IGStoryTrayListAdapterDataSource");
|
||||
if (!cls) return;
|
||||
SEL sel = NSSelectorFromString(@"objectsForListAdapter:");
|
||||
if (!class_getInstanceMethod(cls, sel)) return;
|
||||
MSHookMessageEx(cls, sel, (IMP)hook_objectsForListAdapter, (IMP *)&orig_objectsForListAdapter);
|
||||
}
|
||||
@@ -1,16 +1,25 @@
|
||||
// Hide the trending-searches pill bar under the explore search bar.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
|
||||
%group HideTrendingSearchesGroup
|
||||
%hook IGDSSegmentedPillBarView
|
||||
- (void)didMoveToWindow {
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (![[self delegate] isKindOfClass:%c(IGSearchTypeaheadNavigationHeaderView)]) return;
|
||||
self.hidden = YES;
|
||||
}
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
if (![[self delegate] isKindOfClass:%c(IGSearchTypeaheadNavigationHeaderView)]) return;
|
||||
self.hidden = YES;
|
||||
}
|
||||
%end
|
||||
%end
|
||||
|
||||
if ([[self delegate] isKindOfClass:%c(IGSearchTypeaheadNavigationHeaderView)]) {
|
||||
if ([SCIUtils getBoolPref:@"hide_trending_searches"]) {
|
||||
NSLog(@"[SCInsta] Hiding trending searches");
|
||||
|
||||
[self removeFromSuperview];
|
||||
}
|
||||
%ctor {
|
||||
if ([SCIUtils getBoolPref:@"hide_trending_searches"]) {
|
||||
%init(HideTrendingSearchesGroup);
|
||||
}
|
||||
}
|
||||
%end
|
||||
@@ -1,15 +1,12 @@
|
||||
// Download highlight cover image from the profile long-press menu.
|
||||
// Captures the long-pressed IGStoryTrayCell, finds the IGImageView inside it,
|
||||
// and saves the cover using the user's download settings.
|
||||
// View highlight cover — opens the cover image in the full-screen media viewer.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../Downloader/Download.h"
|
||||
#import "../../ActionButton/SCIMediaViewer.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static SCIDownloadDelegate *sciHighlightDl = nil;
|
||||
|
||||
// Find the IGStoryTrayCell with an active long-press gesture
|
||||
static UIView *sciFindLongPressedCell(UIView *root) {
|
||||
Class cellCls = NSClassFromString(@"IGStoryTrayCell");
|
||||
@@ -46,29 +43,20 @@ static UIImage *sciCoverImageFromCell(UIView *cell) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
static void sciSaveCoverImage(UIImage *image, UIViewController *presenter) {
|
||||
static void sciViewCoverImage(UIImage *image) {
|
||||
if (!image) {
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not find cover image"];
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find cover image")];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *method = [SCIUtils getStringPref:@"dw_save_action"];
|
||||
if ([method isEqualToString:@"photos"]) {
|
||||
// Save to Photos (respects RyukGram album pref)
|
||||
NSData *data = UIImageJPEGRepresentation(image, 1.0);
|
||||
if (!data) return;
|
||||
NSString *tmpPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"%@.jpg", [[NSUUID UUID] UUIDString]]];
|
||||
[data writeToFile:tmpPath atomically:YES];
|
||||
NSURL *tmpURL = [NSURL fileURLWithPath:tmpPath];
|
||||
sciHighlightDl = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:NO];
|
||||
[sciHighlightDl downloadDidFinishWithFileURL:tmpURL];
|
||||
} else {
|
||||
// Share sheet
|
||||
UIActivityViewController *activityVC = [[UIActivityViewController alloc]
|
||||
initWithActivityItems:@[image] applicationActivities:nil];
|
||||
if (presenter) [presenter presentViewController:activityVC animated:YES completion:nil];
|
||||
}
|
||||
// Save to temp and open in the media viewer
|
||||
NSData *data = UIImageJPEGRepresentation(image, 1.0);
|
||||
if (!data) return;
|
||||
NSString *tmpPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"cover_%@.jpg", [[NSUUID UUID] UUIDString]]];
|
||||
[data writeToFile:tmpPath atomically:YES];
|
||||
NSURL *tmpURL = [NSURL fileURLWithPath:tmpPath];
|
||||
[SCIMediaViewer showWithVideoURL:nil photoURL:tmpURL caption:nil];
|
||||
}
|
||||
|
||||
// Stored reference to the long-pressed cell (captured at presentation time)
|
||||
@@ -90,16 +78,15 @@ static void new_present(id self, SEL _cmd, id vc, BOOL animated, id completion)
|
||||
if (actions && actions.count >= 2 && actions.count <= 6) {
|
||||
Class actionCls = NSClassFromString(@"IGActionSheetControllerAction");
|
||||
if (actionCls) {
|
||||
__weak UIViewController *weakSelf = (UIViewController *)self;
|
||||
void (^handler)(void) = ^{
|
||||
UIImage *cover = sciCoverImageFromCell(sciLongPressedHighlightCell);
|
||||
sciSaveCoverImage(cover, weakSelf);
|
||||
sciViewCoverImage(cover);
|
||||
};
|
||||
|
||||
SEL initSel = @selector(initWithTitle:subtitle:style:handler:accessibilityIdentifier:accessibilityLabel:);
|
||||
typedef id (*InitFn)(id, SEL, id, id, NSInteger, id, id, id);
|
||||
id newAction = ((InitFn)objc_msgSend)([actionCls alloc], initSel,
|
||||
@"Download cover", nil, 0, handler, nil, nil);
|
||||
@"View cover", nil, 0, handler, nil, nil);
|
||||
|
||||
if (newAction) {
|
||||
NSMutableArray *newActions = [actions mutableCopy];
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Force launch into a chosen tab. Ignored while messages_only is active.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import <objc/message.h>
|
||||
|
||||
static NSString *sciSelectorForLaunchPref(NSString *p) {
|
||||
if ([p isEqualToString:@"feed"]) return @"_timelineButtonPressed";
|
||||
if ([p isEqualToString:@"explore"]) return @"_exploreButtonPressed";
|
||||
if ([p isEqualToString:@"reels"]) return @"_discoverVideoButtonPressed";
|
||||
if ([p isEqualToString:@"inbox"]) return @"_directInboxButtonPressed";
|
||||
if ([p isEqualToString:@"profile"]) return @"_profileButtonPressed";
|
||||
return nil;
|
||||
}
|
||||
|
||||
%hook IGTabBarController
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
if (![SCIUtils getBoolPref:@"messages_only"]) {
|
||||
static BOOL fired = NO;
|
||||
if (!fired) {
|
||||
fired = YES;
|
||||
NSString *pref = [SCIUtils getStringPref:@"launch_tab"];
|
||||
NSString *selName = sciSelectorForLaunchPref(pref);
|
||||
if (selName) {
|
||||
SEL s = NSSelectorFromString(selName);
|
||||
if ([self respondsToSelector:s])
|
||||
((void(*)(id, SEL))objc_msgSend)(self, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,198 @@
|
||||
// Media zoom — long press on feed media to expand in full-screen viewer.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../ActionButton/SCIMediaActions.h"
|
||||
#import "../../ActionButton/SCIMediaViewer.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
// IGFeedItemPageVideoCell declared in InstagramHeaders.h
|
||||
|
||||
static const void *kZoomGestureKey = &kZoomGestureKey;
|
||||
|
||||
static BOOL sciZoomEnabled(void) {
|
||||
return [SCIUtils getBoolPref:@"feed_media_zoom"];
|
||||
}
|
||||
|
||||
// Walk up to the feed's outer collection view (skip carousel inner CVs)
|
||||
static UICollectionView *sciFeedCollectionView(UIView *view) {
|
||||
UIView *v = view;
|
||||
while (v) {
|
||||
if ([v isKindOfClass:[UICollectionView class]]) {
|
||||
NSString *cls = NSStringFromClass([v class]);
|
||||
if (![cls containsString:@"Carousel"] && ![cls containsString:@"Page"])
|
||||
return (UICollectionView *)v;
|
||||
}
|
||||
v = v.superview;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static NSInteger sciFeedSectionForView(UIView *view, UICollectionView *cv) {
|
||||
UIView *v = view;
|
||||
while (v) {
|
||||
if ([v isKindOfClass:[UICollectionViewCell class]]) {
|
||||
NSIndexPath *ip = [cv indexPathForCell:(UICollectionViewCell *)v];
|
||||
if (ip) return ip.section;
|
||||
}
|
||||
v = v.superview;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Extract IGMedia from sibling cells in the same section
|
||||
static IGMedia *sciZoomFeedMedia(UIView *view) {
|
||||
Class mediaClass = NSClassFromString(@"IGMedia");
|
||||
if (!mediaClass) return nil;
|
||||
|
||||
UICollectionView *cv = sciFeedCollectionView(view);
|
||||
if (!cv) return nil;
|
||||
|
||||
NSInteger section = sciFeedSectionForView(view, cv);
|
||||
if (section < 0) return nil;
|
||||
|
||||
for (UICollectionViewCell *cell in cv.visibleCells) {
|
||||
NSIndexPath *path = [cv indexPathForCell:cell];
|
||||
if (!path || path.section != section) continue;
|
||||
|
||||
NSString *cls = NSStringFromClass([cell class]);
|
||||
if (![cls containsString:@"Photo"] && ![cls containsString:@"Video"]
|
||||
&& ![cls containsString:@"Media"] && ![cls containsString:@"Page"]) continue;
|
||||
|
||||
unsigned int count = 0;
|
||||
Class c = object_getClass(cell);
|
||||
while (c && c != [UICollectionViewCell class]) {
|
||||
Ivar *ivars = class_copyIvarList(c, &count);
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
const char *type = ivar_getTypeEncoding(ivars[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
@try {
|
||||
id val = object_getIvar(cell, ivars[i]);
|
||||
if (val && [val isKindOfClass:mediaClass]) { free(ivars); return (IGMedia *)val; }
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
c = class_getSuperclass(c);
|
||||
}
|
||||
|
||||
if ([cell respondsToSelector:@selector(mediaCellFeedItem)]) {
|
||||
id m = ((id(*)(id,SEL))objc_msgSend)(cell, @selector(mediaCellFeedItem));
|
||||
if (m && [m isKindOfClass:mediaClass]) return (IGMedia *)m;
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Carousel page index from the horizontal scroll view in the Page cell
|
||||
static NSInteger sciZoomPageIndex(UIView *view) {
|
||||
UICollectionView *cv = sciFeedCollectionView(view);
|
||||
if (!cv) return 0;
|
||||
|
||||
NSInteger section = sciFeedSectionForView(view, cv);
|
||||
if (section < 0) return 0;
|
||||
|
||||
for (UICollectionViewCell *cell in cv.visibleCells) {
|
||||
NSIndexPath *path = [cv indexPathForCell:cell];
|
||||
if (!path || path.section != section) continue;
|
||||
if (![NSStringFromClass([cell class]) containsString:@"Page"]) continue;
|
||||
|
||||
NSMutableArray *queue = [NSMutableArray arrayWithObject:cell];
|
||||
int scanned = 0;
|
||||
while (queue.count && scanned < 100) {
|
||||
UIView *cur = queue.firstObject; [queue removeObjectAtIndex:0]; scanned++;
|
||||
if ([cur isKindOfClass:[UIScrollView class]] && cur != cv) {
|
||||
UIScrollView *sv = (UIScrollView *)cur;
|
||||
CGFloat pageW = sv.bounds.size.width;
|
||||
if (pageW > 100 && sv.contentSize.width > pageW * 1.5)
|
||||
return (NSInteger)round(sv.contentOffset.x / pageW);
|
||||
}
|
||||
for (UIView *s in cur.subviews) [queue addObject:s];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void sciZoomFired(UILongPressGestureRecognizer *g) {
|
||||
if (g.state != UIGestureRecognizerStateBegan) return;
|
||||
if (!sciZoomEnabled()) return;
|
||||
|
||||
UIView *view = g.view;
|
||||
IGMedia *media = sciZoomFeedMedia(view);
|
||||
if (!media) return;
|
||||
|
||||
NSString *caption = [SCIMediaActions captionForMedia:media];
|
||||
|
||||
if ([SCIMediaActions isCarouselMedia:media]) {
|
||||
NSArray *children = [SCIMediaActions carouselChildrenForMedia:media];
|
||||
NSMutableArray *items = [NSMutableArray array];
|
||||
for (id child in children) {
|
||||
NSURL *v = [SCIUtils getVideoUrlForMedia:(IGMedia *)child];
|
||||
NSURL *p = [SCIUtils getPhotoUrlForMedia:(IGMedia *)child];
|
||||
if (!v && !p) p = [SCIMediaActions bestURLForMedia:child];
|
||||
if (v || p) [items addObject:[SCIMediaViewerItem itemWithVideoURL:v photoURL:p caption:caption]];
|
||||
}
|
||||
if (items.count) {
|
||||
NSInteger idx = sciZoomPageIndex(view);
|
||||
if (idx < 0 || idx >= (NSInteger)items.count) idx = 0;
|
||||
[SCIMediaViewer showItems:items startIndex:idx];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
|
||||
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:media];
|
||||
if (!videoUrl && !photoUrl) photoUrl = [SCIMediaActions bestURLForMedia:media];
|
||||
if (!videoUrl && !photoUrl) return;
|
||||
|
||||
[SCIMediaViewer showWithVideoURL:videoUrl photoURL:photoUrl caption:caption];
|
||||
}
|
||||
|
||||
// MARK: - Gesture setup
|
||||
|
||||
@interface _SCIZoomTarget : NSObject @end
|
||||
@implementation _SCIZoomTarget
|
||||
- (void)fired:(UILongPressGestureRecognizer *)g { sciZoomFired(g); }
|
||||
@end
|
||||
|
||||
static void sciAddZoomGesture(UIView *view) {
|
||||
if (objc_getAssociatedObject(view, kZoomGestureKey)) return;
|
||||
|
||||
_SCIZoomTarget *target = [_SCIZoomTarget new];
|
||||
objc_setAssociatedObject(view, kZoomGestureKey, target, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
|
||||
UILongPressGestureRecognizer *gesture = [[UILongPressGestureRecognizer alloc]
|
||||
initWithTarget:target action:@selector(fired:)];
|
||||
gesture.minimumPressDuration = 0.5;
|
||||
[view addGestureRecognizer:gesture];
|
||||
}
|
||||
|
||||
// MARK: - Hooks
|
||||
|
||||
%hook IGFeedPhotoView
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (self.superview) sciAddZoomGesture(self);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGModernFeedVideoCell.IGModernFeedVideoCell
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (((UIView *)self).superview) sciAddZoomGesture((UIView *)self);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGFeedItemPagePhotoCell
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (self.superview) sciAddZoomGesture((UIView *)self);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGFeedItemPageVideoCell
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (self.superview) sciAddZoomGesture((UIView *)self);
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,124 @@
|
||||
// Messages-only mode — no-op the tab creators we don't want, force inbox at launch.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../SCIChrome.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
static BOOL sciMsgOnly(void) { return [SCIUtils getBoolPref:@"messages_only"]; }
|
||||
static BOOL sciMsgOnlyHideTabBar(void) {
|
||||
return sciMsgOnly() && [SCIUtils getBoolPref:@"messages_only_hide_tabbar"];
|
||||
}
|
||||
|
||||
%hook IGTabBarController
|
||||
|
||||
// Block tab creation entirely so they never enter the buttons array (no gaps).
|
||||
- (void)_createAndConfigureTimelineButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
- (void)_createAndConfigureReelsButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
- (void)_createAndConfigureExploreButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
- (void)_createAndConfigureCameraButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
- (void)_createAndConfigureDynamicTabButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
- (void)_createAndConfigureNewsButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
- (void)_createAndConfigureStreamsButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
|
||||
// Force initial selection to inbox once after the tab bar has fully laid out.
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
static BOOL launched = NO;
|
||||
if (sciMsgOnly() && !launched) {
|
||||
launched = YES;
|
||||
SEL s = NSSelectorFromString(@"_directInboxButtonPressed");
|
||||
if ([self respondsToSelector:s])
|
||||
((void(*)(id, SEL))objc_msgSend)(self, s);
|
||||
}
|
||||
}
|
||||
|
||||
- (void)viewDidLayoutSubviews {
|
||||
%orig;
|
||||
if (!sciMsgOnlyHideTabBar()) return;
|
||||
Ivar tbIv = class_getInstanceVariable([self class], "_tabBar");
|
||||
UIView *tabBar = tbIv ? object_getIvar(self, tbIv) : nil;
|
||||
if (tabBar) {
|
||||
tabBar.hidden = YES;
|
||||
tabBar.alpha = 0.0;
|
||||
}
|
||||
UIViewController *selected = [self valueForKey:@"selectedViewController"];
|
||||
if (selected.isViewLoaded) {
|
||||
selected.view.frame = self.view.bounds;
|
||||
}
|
||||
}
|
||||
|
||||
// Surface enum no longer maps cleanly to the trimmed _buttons array, so flip
|
||||
// the selected state ourselves and nudge the liquid-glass indicator.
|
||||
%new - (void)sciSyncTabBarSelection:(NSString *)which {
|
||||
Class c = [self class];
|
||||
Ivar ibIv = class_getInstanceVariable(c, "_directInboxButton");
|
||||
Ivar pbIv = class_getInstanceVariable(c, "_profileButton");
|
||||
UIButton *inbox = ibIv ? object_getIvar(self, ibIv) : nil;
|
||||
UIButton *profile = pbIv ? object_getIvar(self, pbIv) : nil;
|
||||
BOOL profileActive = [which isEqualToString:@"profile"];
|
||||
if ([inbox respondsToSelector:@selector(setSelected:)]) inbox.selected = !profileActive;
|
||||
if ([profile respondsToSelector:@selector(setSelected:)]) profile.selected = profileActive;
|
||||
|
||||
// No-op on classic tab bar (selector only exists on IGLiquidGlassInteractiveTabBar).
|
||||
Ivar tbIv = class_getInstanceVariable(c, "_tabBar");
|
||||
id tabBar = tbIv ? object_getIvar(self, tbIv) : nil;
|
||||
NSInteger idx = profileActive ? 1 : 0;
|
||||
SEL setIdx = NSSelectorFromString(@"setSelectedTabBarItemIndex:animateIndicator:");
|
||||
if ([tabBar respondsToSelector:setIdx])
|
||||
((void(*)(id, SEL, NSInteger, BOOL))objc_msgSend)(tabBar, setIdx, idx, YES);
|
||||
}
|
||||
|
||||
- (void)_directInboxButtonPressed {
|
||||
%orig;
|
||||
if (sciMsgOnly())
|
||||
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciSyncTabBarSelection:), @"inbox");
|
||||
}
|
||||
- (void)_profileButtonPressed {
|
||||
%orig;
|
||||
if (sciMsgOnly())
|
||||
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciSyncTabBarSelection:), @"profile");
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// Floating settings button — long-press on tab bar is gone when it's hidden.
|
||||
static const void *kSCIMsgOnlyBtnKey = &kSCIMsgOnlyBtnKey;
|
||||
|
||||
static void sciMsgOnlyInjectSettingsButton(UIViewController *vc) {
|
||||
if (!sciMsgOnlyHideTabBar() || !vc || !vc.isViewLoaded) return;
|
||||
if (objc_getAssociatedObject(vc, kSCIMsgOnlyBtnKey)) return;
|
||||
|
||||
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"gearshape"
|
||||
pointSize:18
|
||||
diameter:36];
|
||||
btn.iconTint = [UIColor labelColor];
|
||||
btn.bubbleColor = [UIColor clearColor];
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[btn addTarget:vc action:@selector(sciMsgOnlyOpenSettings)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
[vc.view addSubview:btn];
|
||||
|
||||
UILayoutGuide *sa = vc.view.safeAreaLayoutGuide;
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.leadingAnchor constraintEqualToAnchor:sa.leadingAnchor constant:12],
|
||||
[btn.topAnchor constraintEqualToAnchor:sa.topAnchor constant:6],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36],
|
||||
]];
|
||||
|
||||
objc_setAssociatedObject(vc, kSCIMsgOnlyBtnKey, btn, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
|
||||
%hook IGDirectInboxViewController
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
%orig;
|
||||
sciMsgOnlyInjectSettingsButton((UIViewController *)self);
|
||||
}
|
||||
|
||||
%new - (void)sciMsgOnlyOpenSettings {
|
||||
UIViewController *vc = (UIViewController *)self;
|
||||
[SCIUtils showSettingsVC:vc.view.window];
|
||||
}
|
||||
%end
|
||||
@@ -13,6 +13,11 @@ BOOL isSurfaceShown(IGMainAppSurfaceIntent *surface) {
|
||||
isShown = NO;
|
||||
}
|
||||
|
||||
// Messages
|
||||
else if ([[surface tabStringFromSurfaceIntent] isEqualToString:@"DIRECT"] && [SCIUtils getBoolPref:@"hide_messages_tab"]) {
|
||||
isShown = NO;
|
||||
}
|
||||
|
||||
// Explore
|
||||
else if ([[surface tabStringFromSurfaceIntent] isEqualToString:@"SEARCH"] && [SCIUtils getBoolPref:@"hide_explore_tab"]) {
|
||||
isShown = NO;
|
||||
@@ -87,14 +92,28 @@ NSArray *filterSurfacesArray(NSArray *surfaces) {
|
||||
}
|
||||
|
||||
- (BOOL)isTabSwipingEnabled {
|
||||
|
||||
// Swipe lands on stripped tabs in messages-only.
|
||||
if ([SCIUtils getBoolPref:@"messages_only"]) return NO;
|
||||
if ([[SCIUtils getStringPref:@"swipe_nav_tabs"] isEqualToString:@"enabled"]) return YES;
|
||||
else if ([[SCIUtils getStringPref:@"swipe_nav_tabs"] isEqualToString:@"disabled"]) return NO;
|
||||
|
||||
return %orig;
|
||||
|
||||
}
|
||||
- (void)setIsTabSwipingEnabled:(BOOL)arg1 {
|
||||
return;
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGHomeFeedHeaderView
|
||||
- (void)didMoveToWindow {
|
||||
%orig;
|
||||
|
||||
if ([SCIUtils getBoolPref:@"hide_messages_tab"]) {
|
||||
UIButton *rightButton = [self valueForKey:@"rightButton"];
|
||||
if (rightButton) {
|
||||
NSLog(@"[SCInsta] Hiding messages tab (on feed)");
|
||||
|
||||
[rightButton removeFromSuperview];
|
||||
}
|
||||
}
|
||||
}
|
||||
%end
|
||||
@@ -38,13 +38,13 @@
|
||||
|
||||
// Recent dm message recipients search bar
|
||||
%hook IGDirectRecipientRecentSearchStorage
|
||||
- (id)initWithDiskManager:(id)arg1 directCache:(id)arg2 userStore:(id)arg3 currentUser:(id)arg4 featureSets:(id)arg5 {
|
||||
- (id)initWithDiskManager:(id)arg1 directRepo:(id)arg2 userMap:(id)arg3 currentUser:(id)arg4 launcherSet:(id)arg5 {
|
||||
if ([SCIUtils getBoolPref:@"no_recent_searches"]) {
|
||||
NSLog(@"[SCInsta] Disabling recent searches");
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
return %orig;
|
||||
return %orig(arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
%end
|
||||
@@ -64,7 +64,7 @@
|
||||
// Section header
|
||||
if ([obj isKindOfClass:%c(IGLabelItemViewModel)]) {
|
||||
// Suggested for you
|
||||
if ([[obj labelTitle] isEqualToString:@"Suggested for you"]) {
|
||||
if ([[obj valueForKey:@"tag"] intValue] == 2) { // 2 == Suggested Users
|
||||
if ([SCIUtils getBoolPref:@"no_suggested_users"]) {
|
||||
NSLog(@"[SCInsta] Hiding suggested users (header: activity feed)");
|
||||
|
||||
|
||||