Skip to content

Commit cec5109

Browse files
Fixes macOS compatibility and extension loading
Addresses macOS compatibility issues with file handling and download URLs Improves extension loading by ensuring `registerGame` is available, queuing registrations if needed, and retrying on failure. Introduces a new deployment method for copying files on macOS instead of creating symlinks for better compatibility with app bundles. Enhances UI styling and resolves minor issues in the dashboard and other components.
1 parent 90d8dac commit cec5109

File tree

20 files changed

+601
-51
lines changed

20 files changed

+601
-51
lines changed

api

Submodule api updated from 2a3f959 to ed44f9b
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { IExtensionApi, IExtensionContext } from '../../types/IExtensionContext';
2+
import { IGame } from '../../types/IGame';
3+
import * as fs from '../../util/fs';
4+
import { log } from '../../util/log';
5+
6+
import { IDiscoveryResult } from '../gamemode_management/types/IDiscoveryResult';
7+
import { getGame } from '../gamemode_management/util/getGame';
8+
import LinkingDeployment from '../mod_management/LinkingDeployment';
9+
import { installPathForGame } from '../mod_management/selectors';
10+
import { IDeployedFile, IDeploymentMethod,
11+
IUnavailableReason } from '../mod_management/types/IDeploymentMethod';
12+
13+
import Promise from 'bluebird';
14+
import { TFunction } from 'i18next';
15+
import * as path from 'path';
16+
17+
class DeploymentMethod extends LinkingDeployment {
18+
public priority: number = 1; // Highest priority to make it default
19+
20+
constructor(api: IExtensionApi) {
21+
super(
22+
'copy_activator', 'Copy Deployment',
23+
'Deploys mods by copying files to the destination directory.',
24+
true,
25+
api);
26+
}
27+
28+
public detailedDescription(t: TFunction): string {
29+
return t(
30+
'This deployment method copies mod files directly to the game directory.\n'
31+
+ 'Advantages:\n'
32+
+ ' - Perfect game compatibility (no symlinks)\n'
33+
+ ' - Works across different drives/partitions\n'
34+
+ ' - No elevation required\n'
35+
+ ' - Compatible with all file systems\n'
36+
+ 'Disadvantages:\n'
37+
+ ' - Uses more disk space (files are duplicated)\n'
38+
+ ' - Slower deployment for large mods\n'
39+
+ ' - Changes to original mod files won\'t be reflected automatically');
40+
}
41+
42+
public isSupported(state: any, gameId: string, typeId: string): IUnavailableReason {
43+
const discovery: IDiscoveryResult = state.settings.gameMode.discovered[gameId];
44+
if ((discovery === undefined) || (discovery.path === undefined)) {
45+
return { description: t => t('Game not discovered.') };
46+
}
47+
48+
const game: IGame = getGame(gameId);
49+
const modPaths = game.getModPaths(discovery.path);
50+
51+
if (modPaths[typeId] === undefined) {
52+
return undefined;
53+
}
54+
55+
try {
56+
fs.accessSync(modPaths[typeId], fs.constants.W_OK);
57+
} catch (err) {
58+
log('info', 'copy deployment not supported due to lack of write access',
59+
{ typeId, path: modPaths[typeId] });
60+
return {
61+
description: t => t('Can\'t write to output directory'),
62+
order: 3,
63+
solution: t => t('To resolve this problem, the current user account needs to '
64+
+ 'be given write permission to "{{modPath}}".', {
65+
replace: {
66+
modPath: modPaths[typeId],
67+
},
68+
}),
69+
};
70+
}
71+
72+
return undefined;
73+
}
74+
75+
protected linkFile(linkPath: string, sourcePath: string, dirTags?: boolean): Promise<void> {
76+
const basePath = path.dirname(linkPath);
77+
return this.ensureDir(basePath, dirTags)
78+
.then(() => fs.copyAsync(sourcePath, linkPath))
79+
.catch(err => {
80+
if (err.code === 'EEXIST') {
81+
// File already exists, remove it and try again
82+
return fs.removeAsync(linkPath)
83+
.then(() => fs.copyAsync(sourcePath, linkPath));
84+
}
85+
return Promise.reject(err);
86+
});
87+
}
88+
89+
protected unlinkFile(linkPath: string): Promise<void> {
90+
return fs.removeAsync(linkPath);
91+
}
92+
93+
protected isLink(linkPath: string, sourcePath: string): Promise<boolean> {
94+
// For copy deployment, we check if the file exists and has the same content
95+
return Promise.all([
96+
fs.statAsync(linkPath).catch(() => null),
97+
fs.statAsync(sourcePath).catch(() => null)
98+
])
99+
.then(([linkStats, sourceStats]) => {
100+
if (!linkStats || !sourceStats) {
101+
return false;
102+
}
103+
// Simple check: same size and modification time
104+
return linkStats.size === sourceStats.size;
105+
});
106+
}
107+
108+
protected canRestore(): boolean {
109+
return true;
110+
}
111+
112+
protected stat(filePath: string): Promise<fs.Stats> {
113+
return fs.statAsync(filePath);
114+
}
115+
116+
protected statLink(filePath: string): Promise<fs.Stats> {
117+
return fs.statAsync(filePath);
118+
}
119+
}
120+
121+
export interface IExtensionContextEx extends IExtensionContext {
122+
registerDeploymentMethod: (activator: IDeploymentMethod) => void;
123+
}
124+
125+
function init(context: IExtensionContextEx): boolean {
126+
context.registerDeploymentMethod(new DeploymentMethod(context.api));
127+
return true;
128+
}
129+
130+
export default init;

src/extensions/dashboard/views/Dashboard.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,9 +212,7 @@ class Dashboard extends ComponentEx<IProps, IComponentState> {
212212
</FlexLayout>
213213
) : (
214214
<div className='dashlet-customize-btn'>
215-
<IconButton icon='edit' tooltip={t('Customize your dashboard')} onClick={this.toggleEdit}>
216-
{t('Customize your dashboard')}
217-
</IconButton>
215+
<IconButton icon='edit' tooltip={t('Customize your dashboard')} onClick={this.toggleEdit} />
218216
</div>
219217
);
220218
}

src/extensions/download_management/DownloadObserver.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { flatten, setdefault, truthy } from '../../util/util';
1212
import { showURL } from '../browser/actions';
1313
import { convertGameIdReverse } from '../nexus_integration/util/convertGameId';
1414
import { knownGames } from '../gamemode_management/selectors';
15+
import { interceptDownloadURLForMacOS } from '../../util/macOSGameCompatibility';
1516

1617
import {
1718
downloadProgress,
@@ -252,6 +253,10 @@ export class DownloadObserver {
252253
urls = [];
253254
}
254255
urls = urls.filter(url => url !== undefined);
256+
257+
// Intercept URLs for macOS compatibility (e.g., Lovely injector)
258+
urls = urls.map(url => interceptDownloadURLForMacOS(url));
259+
255260
if (urls.length === 0) {
256261
if (callback !== undefined) {
257262
callback(new ProcessCanceled('URL not usable, only ftp, http and https are supported.'));
@@ -262,6 +267,12 @@ export class DownloadObserver {
262267

263268
const state: IState = this.mApi.store.getState();
264269
let gameId: string = (modInfo || {}).game || selectors.activeGameId(state);
270+
271+
// Debug logging for macOS compatibility
272+
log('debug', 'DownloadObserver: Processing URLs for macOS compatibility', {
273+
originalUrls: urls,
274+
gameId: gameId
275+
});
265276
if (Array.isArray(gameId)) {
266277
gameId = gameId[0];
267278
}

src/extensions/extension_manager/installExtension.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ class ContextProxyHandler implements ProxyHandler<any> {
4444
setShowInMenu() { /* ignore in this context */ },
4545
},
4646
};
47+
} else if (typeof key === 'string' && key.startsWith('register')) {
48+
// Provide stub functions for all register* methods during dependency detection
49+
return (...args: any[]) => {
50+
// During dependency detection, we just need to prevent errors
51+
// The actual registration will happen during proper extension initialization
52+
return undefined;
53+
};
4754
}
4855
}
4956

@@ -170,23 +177,29 @@ async function installExtensionDependencies(api: IExtensionApi, extPath: string)
170177

171178
// Ensure the global assignment is available before calling the extension
172179
// Use setImmediate to allow the global assignment to complete
173-
await new Promise<void>((resolve) => {
180+
await new Promise<void>((resolve, reject) => {
174181
setImmediate(() => {
175182
try {
176183
extension.default(context);
177184
resolve();
178185
} catch (err) {
179186
// If the extension still fails, try passing vortexExt directly as a fallback
180-
if (err.message?.includes('registerGame is not a function')) {
187+
const errorMessage = err.message || err.toString();
188+
if (errorMessage.includes('registerGame is not a function') ||
189+
errorMessage.includes('context.registerGame is not a function')) {
181190
try {
182191
// Some extensions might expect vortexExt as a parameter
183192
extension.default(vortexExt);
184193
resolve();
185194
} catch (fallbackErr) {
186-
throw err; // Throw the original error
195+
// If vortexExt also fails, it means registerGame is not available yet
196+
// This can happen if gamemode_management hasn't initialized yet
197+
reject(new Error(`Extension failed to load: ${errorMessage}. ` +
198+
`This may be due to gamemode_management not being initialized yet. ` +
199+
`Please ensure gamemode_management extension is loaded first.`));
187200
}
188201
} else {
189-
throw err;
202+
reject(err);
190203
}
191204
}
192205
});

src/extensions/extension_manager/util.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,13 @@ export function downloadGithubRelease(api: IExtensionApi,
405405
}
406406

407407
export function downloadFile(url: string, outputPath: string): Promise<void> {
408-
return Promise.resolve(rawRequest(url))
408+
// Import the macOS compatibility function
409+
const { interceptDownloadURLForMacOS } = require('../../util/macOSGameCompatibility');
410+
411+
// Apply macOS URL interception
412+
const interceptedUrl = interceptDownloadURLForMacOS(url);
413+
414+
return Promise.resolve(rawRequest(interceptedUrl))
409415
.then((data: Buffer) => fs.writeFileAsync(outputPath, data));
410416
}
411417

src/extensions/gamemode_management/util/discovery.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import * as fsExtra from 'fs-extra';
2828
import * as path from 'path';
2929
import turbowalk from 'turbowalk';
3030
import { discoverMacOSGames } from '../../../util/macOSGameDiscovery';
31+
import { checkFileWithMacOSFallback } from '../../../util/macOSGameCompatibility';
3132
import { isMacOS } from '../../../util/platform';
3233
const winapi = isWindows() ? (isWindows() ? require('winapi-bindings') : undefined) : undefined;
3334

@@ -436,10 +437,27 @@ function verifyToolDir(tool: ITool, testPath: string): Bluebird<void> {
436437
// our fs overload would try to acquire access to the directory if it's locked, which
437438
// is not something we want at this point because we don't even know yet if the user
438439
// wants to manage the game at all.
439-
(fileName: string) => fsExtra.stat(path.join(testPath, fileName))
440-
.catch(err => {
441-
return Bluebird.reject(err);
442-
}))
440+
(fileName: string) => {
441+
// Use macOS compatibility layer for file validation
442+
if (isMacOS()) {
443+
return checkFileWithMacOSFallback(testPath, fileName, tool.id)
444+
.then((exists) => {
445+
if (!exists) {
446+
const error = new Error(`File not found: ${fileName}`);
447+
(error as any).code = 'ENOENT';
448+
(error as any).path = path.join(testPath, fileName);
449+
throw error;
450+
}
451+
return undefined;
452+
});
453+
} else {
454+
return fsExtra.stat(path.join(testPath, fileName))
455+
.then(() => undefined)
456+
.catch(err => {
457+
return Bluebird.reject(err);
458+
});
459+
}
460+
})
443461
.then(() => undefined);
444462
}
445463

src/extensions/installer_fomod/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { delayed, toPromise, truthy} from '../../util/util';
1717
import { getGame } from '../gamemode_management/util/getGame';
1818
import { ArchiveBrokenError } from '../mod_management/InstallManager';
1919
import { IMod } from '../mod_management/types/IMod';
20-
import { isWindows, getCurrentPlatform } from '../../util/platform';
20+
import { isWindows, isMacOS, getCurrentPlatform } from '../../util/platform';
2121

2222
import { clearDialog, endDialog, setInstallerDataPath } from './actions/installerUI';
2323
import { setInstallerSandbox } from './actions/settings';
@@ -1206,6 +1206,13 @@ async function createIsolatedConnection(securityLevel: SecurityLevel): Promise<C
12061206
async function testSupportedScripted(securityLevel: SecurityLevel,
12071207
files: string[])
12081208
: Promise<ISupportedResult> {
1209+
// On macOS, FOMOD installer is not functional (mock implementation only)
1210+
// Return unsupported to allow basic installer to handle the installation
1211+
if (isMacOS()) {
1212+
log('debug', '[installer] FOMOD installer not supported on macOS, using basic installer');
1213+
return { supported: false, requiredFiles: [] };
1214+
}
1215+
12091216
let connection: ConnectionIPC;
12101217
try {
12111218
connection = await createIsolatedConnection(securityLevel);
@@ -1228,6 +1235,13 @@ async function testSupportedScripted(securityLevel: SecurityLevel,
12281235
async function testSupportedFallback(securityLevel: SecurityLevel,
12291236
files: string[])
12301237
: Promise<ISupportedResult> {
1238+
// On macOS, FOMOD installer is not functional (mock implementation only)
1239+
// Return unsupported to allow basic installer to handle the installation
1240+
if (isMacOS()) {
1241+
log('debug', '[installer] FOMOD fallback installer not supported on macOS, using basic installer');
1242+
return { supported: false, requiredFiles: [] };
1243+
}
1244+
12311245
let connection: ConnectionIPC;
12321246
try {
12331247
connection = await createIsolatedConnection(securityLevel);

0 commit comments

Comments
 (0)