Skip to content

feat: allow injecting into all extensions#592

Open
waruhachi wants to merge 4 commits intoclaration:mainfrom
waruhachi:main
Open

feat: allow injecting into all extensions#592
waruhachi wants to merge 4 commits intoclaration:mainfrom
waruhachi:main

Conversation

@waruhachi
Copy link

@waruhachi waruhachi commented Feb 25, 2026

This pull request adds support for injecting tweaks into all app extensions, including plugins and extensions, in addition to the main app bundle as mentioned in #584.

A toggle button is added in the Tweak section of the Modify settings. This setting is not persistent and is disabled by default.

Image

Copilot AI review requested due to automatic review settings February 25, 2026 17:31
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds support for injecting tweaks into all app extensions (both PlugIns and Extensions directories) in addition to the main app bundle. The feature is controlled by a new non-persistent toggle in the Modify settings UI.

Changes:

  • Added a new injectIntoExtensions boolean option to the Options model
  • Implemented extension discovery and injection logic in TweakHandler
  • Added UI toggle in SigningTweaksView with localization support

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 12 comments.

File Description
Feather/Views/Signing/SigningTweaksView.swift Added toggle control for extension injection feature
Feather/Utilities/Handlers/TweakHandler.swift Added extension discovery, injection path calculation, and injection logic for .appex bundles
Feather/Resources/Localizable.xcstrings Added localization entry for "Inject into Extensions"
Feather/Backend/Observable/OptionsManager.swift Added injectIntoExtensions property with default value of false

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 304 to 311
let injectPath: String
if _options.injectPath == .rpath {
injectPath = "@rpath/\(dylibName)"
} else {
if injectFolder == .frameworks {
injectPath = "@executable_path/../../Frameworks/\(dylibName)"
} else {
injectPath = "@executable_path/../../\(dylibName)"
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The injection path calculation for extensions assumes dylibs are in the main app bundle's root or Frameworks directory and uses ../../ to navigate from the extension executable to the main app. However, this path construction may be incorrect depending on the actual location of extensions. For .appex bundles in PlugIns/, the path would be AppName.app/PlugIns/Extension.appex/Extension, so ../../ would resolve to AppName.app/. For .appex bundles in Extensions/, it's the same structure. This appears correct, but it would be safer to verify this works for both PlugIns and Extensions directories, especially if extensions have nested subdirectories.

Suggested change
let injectPath: String
if _options.injectPath == .rpath {
injectPath = "@rpath/\(dylibName)"
} else {
if injectFolder == .frameworks {
injectPath = "@executable_path/../../Frameworks/\(dylibName)"
} else {
injectPath = "@executable_path/../../\(dylibName)"
// Compute how many levels above the extension bundle the .app bundle is,
// so we can build a robust @executable_path-relative injection path even
// when extensions are nested under additional subdirectories.
let bundlePathComponents = extensionBundle.bundleURL.pathComponents
let appIndex = bundlePathComponents.lastIndex(where: { $0.hasSuffix(".app") })
var relativeToApp: String?
if let appIndex {
let levelsUp = bundlePathComponents.count - (appIndex + 1)
if levelsUp > 0 {
let ups = Array(repeating: "..", count: levelsUp)
relativeToApp = ups.joined(separator: "/")
} else {
// Extension bundle is already at the app level.
relativeToApp = "."
}
} else {
// Fallback to previous behavior if we can't locate the .app bundle.
Logger.misc.warning("Could not determine app bundle for extension \(extensionURL.lastPathComponent); falling back to ../../ for injection path")
relativeToApp = "../.."
}
let injectPath: String
if _options.injectPath == .rpath {
injectPath = "@rpath/\(dylibName)"
} else {
let basePath: String
if relativeToApp == "." {
basePath = "@executable_path"
} else if let relativeToApp {
basePath = "@executable_path/\(relativeToApp)"
} else {
// Should not happen, but fall back defensively.
basePath = "@executable_path/../../"
}
if injectFolder == .frameworks {
injectPath = "\(basePath)/Frameworks/\(dylibName)"
} else {
injectPath = "\(basePath)/\(dylibName)"

Copilot uses AI. Check for mistakes.
private let _fileManager = FileManager.default
private var _urlsToInject: [URL] = []
private var _directoriesToCheck: [URL] = []
private var _injectedDylibNames: [String] = []
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: this line uses spaces instead of tabs, while the rest of the file uses tabs for indentation. This should use a tab character to match the surrounding code style.

Suggested change
private var _injectedDylibNames: [String] = []
private var _injectedDylibNames: [String] = []

Copilot uses AI. Check for mistakes.
Comment on lines 98 to 102

// inject into all extensions if enabled
if !_injectedDylibNames.isEmpty {
_injectIntoAllExtensions(dylibNames: _injectedDylibNames)
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: these lines use spaces instead of tabs. The rest of the file uses tabs for indentation, so these should be updated to use tabs to match the codebase style.

Copilot uses AI. Check for mistakes.
with: "\(_options.injectPath.rawValue)\(injectFolder.rawValue)\(destinationURL.lastPathComponent)"
)

_injectedDylibNames.append(destinationURL.lastPathComponent)
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: this line uses spaces instead of tabs. The rest of the file uses tabs for indentation, so this should be updated to use tabs to match the codebase style.

Suggested change
_injectedDylibNames.append(destinationURL.lastPathComponent)
_injectedDylibNames.append(destinationURL.lastPathComponent)

Copilot uses AI. Check for mistakes.
removeURLScheme: false,
removeProvisioning: false,
changeLanguageFilesForCustomDisplayName: false,
injectIntoExtensions: false,
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: this line uses spaces instead of tabs. The rest of the file uses tabs for indentation, so this should be updated to use tabs to match the codebase style.

Suggested change
injectIntoExtensions: false,
injectIntoExtensions: false,

Copilot uses AI. Check for mistakes.
Comment on lines 305 to 313
if _options.injectPath == .rpath {
injectPath = "@rpath/\(dylibName)"
} else {
if injectFolder == .frameworks {
injectPath = "@executable_path/../../Frameworks/\(dylibName)"
} else {
injectPath = "@executable_path/../../\(dylibName)"
}
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using @rpath for extension injection, the code assumes the dylib will be found via @rpath, but extensions may not have the same rpath configuration as the main app. Extensions typically need their own LC_RPATH entries pointing to the main app's Frameworks directory (e.g., @loader_path/../../Frameworks). The current implementation might fail if the extension's rpath doesn't include the location where the dylib is stored. Consider either documenting this limitation or adding rpath entries to the extension executables to ensure they can locate dylibs at @rpath.

Copilot uses AI. Check for mistakes.
Comment on lines 291 to 345
/// Injects a dylib into an extension's executable
private func _injectIntoExtension(extensionURL: URL, dylibName: String) {
guard let extensionBundle = Bundle(url: extensionURL),
let extensionExecutable = extensionBundle.executableURL else {
Logger.misc.warning("Skipping \(extensionURL.lastPathComponent): couldn't read bundle")
return
}

var injectFolder = _options.injectFolder
if _options.injectPath == .rpath && _options.injectFolder == .frameworks {
injectFolder = .root
}

let injectPath: String
if _options.injectPath == .rpath {
injectPath = "@rpath/\(dylibName)"
} else {
if injectFolder == .frameworks {
injectPath = "@executable_path/../../Frameworks/\(dylibName)"
} else {
injectPath = "@executable_path/../../\(dylibName)"
}
}

let success = Zsign.injectDyLib(
appExecutable: extensionExecutable.path,
with: injectPath
)

if success {
Logger.misc.info("Injected \(dylibName) into extension: \(extensionURL.lastPathComponent)")
} else {
Logger.misc.warning("Failed to inject into extension: \(extensionURL.lastPathComponent)")
}
}

/// Injects all dylibs into all discovered extensions
private func _injectIntoAllExtensions(dylibNames: [String]) {
guard _options.injectIntoExtensions else { return }

let extensions = _discoverAppExtensions()

guard !extensions.isEmpty else {
Logger.misc.info("No app extensions found for injection")
return
}

Logger.misc.info("Found \(extensions.count) app extension(s) for injection")

for extensionURL in extensions {
for dylibName in dylibNames {
_injectIntoExtension(extensionURL: extensionURL, dylibName: dylibName)
}
}
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: the entire new section (lines 261-345) uses spaces instead of tabs. The rest of the file uses tabs for indentation, so all of these lines should use tabs to match the codebase style.

Suggested change
/// Injects a dylib into an extension's executable
private func _injectIntoExtension(extensionURL: URL, dylibName: String) {
guard let extensionBundle = Bundle(url: extensionURL),
let extensionExecutable = extensionBundle.executableURL else {
Logger.misc.warning("Skipping \(extensionURL.lastPathComponent): couldn't read bundle")
return
}
var injectFolder = _options.injectFolder
if _options.injectPath == .rpath && _options.injectFolder == .frameworks {
injectFolder = .root
}
let injectPath: String
if _options.injectPath == .rpath {
injectPath = "@rpath/\(dylibName)"
} else {
if injectFolder == .frameworks {
injectPath = "@executable_path/../../Frameworks/\(dylibName)"
} else {
injectPath = "@executable_path/../../\(dylibName)"
}
}
let success = Zsign.injectDyLib(
appExecutable: extensionExecutable.path,
with: injectPath
)
if success {
Logger.misc.info("Injected \(dylibName) into extension: \(extensionURL.lastPathComponent)")
} else {
Logger.misc.warning("Failed to inject into extension: \(extensionURL.lastPathComponent)")
}
}
/// Injects all dylibs into all discovered extensions
private func _injectIntoAllExtensions(dylibNames: [String]) {
guard _options.injectIntoExtensions else { return }
let extensions = _discoverAppExtensions()
guard !extensions.isEmpty else {
Logger.misc.info("No app extensions found for injection")
return
}
Logger.misc.info("Found \(extensions.count) app extension(s) for injection")
for extensionURL in extensions {
for dylibName in dylibNames {
_injectIntoExtension(extensionURL: extensionURL, dylibName: dylibName)
}
}
}
/// Injects a dylib into an extension's executable
private func _injectIntoExtension(extensionURL: URL, dylibName: String) {
guard let extensionBundle = Bundle(url: extensionURL),
let extensionExecutable = extensionBundle.executableURL else {
Logger.misc.warning("Skipping \(extensionURL.lastPathComponent): couldn't read bundle")
return
}
var injectFolder = _options.injectFolder
if _options.injectPath == .rpath && _options.injectFolder == .frameworks {
injectFolder = .root
}
let injectPath: String
if _options.injectPath == .rpath {
injectPath = "@rpath/\(dylibName)"
} else {
if injectFolder == .frameworks {
injectPath = "@executable_path/../../Frameworks/\(dylibName)"
} else {
injectPath = "@executable_path/../../\(dylibName)"
}
}
let success = Zsign.injectDyLib(
appExecutable: extensionExecutable.path,
with: injectPath
)
if success {
Logger.misc.info("Injected \(dylibName) into extension: \(extensionURL.lastPathComponent)")
} else {
Logger.misc.warning("Failed to inject into extension: \(extensionURL.lastPathComponent)")
}
}
/// Injects all dylibs into all discovered extensions
private func _injectIntoAllExtensions(dylibNames: [String]) {
guard _options.injectIntoExtensions else { return }
let extensions = _discoverAppExtensions()
guard !extensions.isEmpty else {
Logger.misc.info("No app extensions found for injection")
return
}
Logger.misc.info("Found \(extensions.count) app extension(s) for injection")
for extensionURL in extensions {
for dylibName in dylibNames {
_injectIntoExtension(extensionURL: extensionURL, dylibName: dylibName)
}
}
}

Copilot uses AI. Check for mistakes.
Comment on lines 33 to 36

Toggle(isOn: $options.injectIntoExtensions) {
Label(.localized("Inject into Extensions"), systemImage: "syringe")
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: these lines use spaces instead of tabs. The rest of the file appears to use tabs for indentation, so these should be updated to use tabs to match the codebase style.

Suggested change
Toggle(isOn: $options.injectIntoExtensions) {
Label(.localized("Inject into Extensions"), systemImage: "syringe")
}
Toggle(isOn: $options.injectIntoExtensions) {
Label(.localized("Inject into Extensions"), systemImage: "syringe")
}

Copilot uses AI. Check for mistakes.
Comment on lines 103 to 104
/// If tweaks should be injected into all app extensions (PlugIns and Extensions)
var injectIntoExtensions: Bool
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: these lines use spaces instead of tabs. The rest of the file uses tabs for indentation, so these should be updated to use tabs to match the codebase style.

Suggested change
/// If tweaks should be injected into all app extensions (PlugIns and Extensions)
var injectIntoExtensions: Bool
/// If tweaks should be injected into all app extensions (PlugIns and Extensions)
var injectIntoExtensions: Bool

Copilot uses AI. Check for mistakes.
}

// inject into all extensions if enabled
if !_injectedDylibNames.isEmpty {
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conditional check if !_injectedDylibNames.isEmpty will prevent extension injection from running if no dylibs were injected into the main app. However, this doesn't account for the case where _options.injectIntoExtensions is false - the check inside _injectIntoAllExtensions will handle that, but it would be more efficient and clearer to combine both conditions here: if !_injectedDylibNames.isEmpty && _options.injectIntoExtensions. This would avoid the function call overhead when the feature is disabled.

Suggested change
if !_injectedDylibNames.isEmpty {
if !_injectedDylibNames.isEmpty && _options.injectIntoExtensions {

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants