Files
streamyfin/plugins/withTVOSTopShelf.ts
Gauvino 7054137690 refactor: migrate app.config and Expo config plugins to TypeScript
Migrate the dynamic Expo config and all 12 local config plugins from
CommonJS .js to typed TypeScript:

- app.config.js -> app.config.ts (typed ConfigContext/ExpoConfig,
  behavior-identical port)
- plugins/*.js -> plugins/*.ts with `ConfigPlugin` typings from
  expo/config-plugins; plugin options are now type-checked (withGitPod)
- app.json plugin references updated to the .ts paths
- imports unified on expo/config-plugins (some plugins used the
  @expo/config-plugins alias)

Node evaluates the config at prebuild time and cannot parse TypeScript
plugin modules on its own (verified empirically: Expo transpiles
app.config.ts itself but not its imports), so the documented tsx
approach is used: `import "tsx/cjs"` at the top of app.config.ts plus
tsx as a devDependency.

Validation: resolved prebuild configs (expo config --type prebuild) are
byte-identical to the old JS config for both mobile and TV (modulo
plugin path extensions and the builtAt timestamp); full
`bun run prebuild` and `bun run prebuild:tv` pass and all Android
plugin mods are present in the generated project (media3 exclusions,
gradle properties, cast activity, network security config, alert
colors).
2026-06-11 12:20:31 +02:00

214 lines
6.5 KiB
TypeScript

import type { ExpoConfig } from "expo/config";
import {
type ConfigPlugin,
withEntitlementsPlist,
withInfoPlist,
withXcodeProject,
} from "expo/config-plugins";
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier";
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
"StreamyfinKeychainAccessGroupIdentifier";
interface AppExtensionConfig {
targetName: string;
bundleIdentifier: string;
entitlements: {
"com.apple.security.application-groups": string[];
"keychain-access-groups": string[];
};
}
function getBundleIdentifier(config: ExpoConfig): string {
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
}
function getAppGroupIdentifier(config: ExpoConfig): string {
return `group.${getBundleIdentifier(config)}.tvtopshelf`;
}
function getKeychainAccessGroupIdentifier(config: ExpoConfig): string {
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
}
// The xcode project object has no usable typings — keep `any` here.
function getBuildConfigurations(project: any, configurationListId: string) {
const configurationList =
project.hash.project.objects.XCConfigurationList[configurationListId];
if (!configurationList?.buildConfigurations) return [];
const buildConfigurations = project.pbxXCBuildConfigurationSection();
return configurationList.buildConfigurations
.map((config: { value: string }) => buildConfigurations[config.value])
.filter(Boolean);
}
function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] {
const groups = Array.isArray(value) ? value : [];
return groups.includes(appGroupIdentifier)
? groups
: [...groups, appGroupIdentifier];
}
function ensureKeychainAccessGroup(
value: unknown,
keychainAccessGroupIdentifier: string,
): string[] {
const groups = Array.isArray(value) ? value : [];
return groups.includes(keychainAccessGroupIdentifier)
? groups
: [...groups, keychainAccessGroupIdentifier];
}
function ensureAppExtension(
appExtensions: unknown,
targetName: string,
bundleIdentifier: string,
appGroupIdentifier: string,
keychainAccessGroupIdentifier: string,
): AppExtensionConfig[] {
const extensionConfig: AppExtensionConfig = {
targetName,
bundleIdentifier,
entitlements: {
"com.apple.security.application-groups": [appGroupIdentifier],
"keychain-access-groups": [keychainAccessGroupIdentifier],
},
};
const extensions: AppExtensionConfig[] = Array.isArray(appExtensions)
? appExtensions
: [];
// Keep plugin runs idempotent and preserve unrelated app extension entries.
const existingIndex = extensions.findIndex(
(appExtension) => appExtension?.targetName === targetName,
);
if (existingIndex === -1) {
return [...extensions, extensionConfig];
}
return extensions.map((appExtension, index) =>
index === existingIndex ? extensionConfig : appExtension,
);
}
const withTVOSTopShelf: ConfigPlugin = (config) => {
const appGroupIdentifier = getAppGroupIdentifier(config);
const keychainAccessGroupIdentifier =
getKeychainAccessGroupIdentifier(config);
const bundleIdentifier = getBundleIdentifier(config);
const extensionBundleIdentifier = `${bundleIdentifier}.tvtopshelf`;
const isTVBuild = process.env.EXPO_TV === "1";
if (isTVBuild) {
config.extra = {
...config.extra,
eas: {
...config.extra?.eas,
build: {
...config.extra?.eas?.build,
experimental: {
...config.extra?.eas?.build?.experimental,
ios: {
...config.extra?.eas?.build?.experimental?.ios,
appExtensions: ensureAppExtension(
config.extra?.eas?.build?.experimental?.ios?.appExtensions,
EXTENSION_TARGET_NAME,
extensionBundleIdentifier,
appGroupIdentifier,
keychainAccessGroupIdentifier,
),
},
},
},
},
};
config = withInfoPlist(config, (config) => {
config.modResults[APP_GROUP_INFO_PLIST_KEY] = appGroupIdentifier;
config.modResults[KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY] =
keychainAccessGroupIdentifier;
return config;
});
config = withEntitlementsPlist(config, (config) => {
config.modResults["com.apple.security.application-groups"] =
ensureAppGroup(
config.modResults["com.apple.security.application-groups"],
appGroupIdentifier,
);
config.modResults["keychain-access-groups"] = ensureKeychainAccessGroup(
config.modResults["keychain-access-groups"],
keychainAccessGroupIdentifier,
);
return config;
});
}
if (!isTVBuild) {
return config;
}
return withXcodeProject(config, (config) => {
const project = config.modResults;
if (project.pbxTargetByName(EXTENSION_TARGET_NAME)) {
return config;
}
const target = project.addTarget(
EXTENSION_TARGET_NAME,
"app_extension",
EXTENSION_TARGET_NAME,
extensionBundleIdentifier,
);
project.addBuildPhase(
[`${TARGET_SOURCE_DIR}/TopShelfProvider.swift`],
"PBXSourcesBuildPhase",
"Sources",
target.uuid,
);
project.addBuildPhase(
["TVServices.framework"],
"PBXFrameworksBuildPhase",
"Frameworks",
target.uuid,
);
const buildConfigurations = getBuildConfigurations(
project,
target.pbxNativeTarget.buildConfigurationList,
);
for (const buildConfig of buildConfigurations) {
buildConfig.buildSettings = {
...buildConfig.buildSettings,
CODE_SIGN_ENTITLEMENTS: `${TARGET_SOURCE_DIR}/${EXTENSION_TARGET_NAME}.entitlements`,
APPLICATION_EXTENSION_API_ONLY: "YES",
CURRENT_PROJECT_VERSION: "1",
INFOPLIST_FILE: `${TARGET_SOURCE_DIR}/Info.plist`,
IPHONEOS_DEPLOYMENT_TARGET:
buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET || "15.6",
MARKETING_VERSION: config.version || "1.0",
PRODUCT_BUNDLE_IDENTIFIER: extensionBundleIdentifier,
PRODUCT_NAME: `"${EXTENSION_TARGET_NAME}"`,
SDKROOT: "appletvos",
SKIP_INSTALL: "YES",
SWIFT_VERSION: "5.9",
APP_GROUP_IDENTIFIER: appGroupIdentifier,
KEYCHAIN_ACCESS_GROUP_IDENTIFIER: `"${keychainAccessGroupIdentifier}"`,
SUPPORTED_PLATFORMS: '"appletvos appletvsimulator"',
TARGETED_DEVICE_FAMILY: "3",
};
}
return config;
});
};
export default withTVOSTopShelf;