feat: refactor desktop implement with brand new 2.0

This commit is contained in:
Innei
2025-12-19 23:09:24 +08:00
committed by arvinxx
parent b5720434e4
commit 10e048c9c5
80 changed files with 4327 additions and 1636 deletions

View File

@@ -0,0 +1,134 @@
import fs from 'fs-extra';
import { execSync } from 'node:child_process';
import path from 'node:path';
import { runPrebuild } from '../prebuild.mjs';
import { modifySourceForElectron } from './modifiers/index.mjs';
const PROJECT_ROOT = process.cwd();
const TEMP_DIR = path.join(PROJECT_ROOT, 'tmp', 'desktop-build');
const foldersToSymlink = [
'node_modules',
'packages',
'public',
'locales',
'docs',
'.cursor',
'apps',
];
const foldersToCopy = ['src', 'scripts'];
const filesToCopy = [
'package.json',
'tsconfig.json',
'next.config.ts',
'pnpm-workspace.yaml',
'bun.lockb',
'.npmrc',
'.bunfig.toml',
'.eslintrc.js',
'.eslintignore',
'.prettierrc.cjs',
'.prettierignore',
'drizzle.config.ts',
'postcss.config.js',
'tailwind.config.ts',
'tailwind.config.js',
];
const build = async () => {
console.log('🚀 Starting Electron App Build in Shadow Workspace...');
console.log(`📂 Workspace: ${TEMP_DIR}`);
if (fs.existsSync(TEMP_DIR)) {
await fs.remove(TEMP_DIR);
}
await fs.ensureDir(TEMP_DIR);
console.log('🔗 Symlinking dependencies and static assets...');
for (const folder of foldersToSymlink) {
const srcPath = path.join(PROJECT_ROOT, folder);
const destPath = path.join(TEMP_DIR, folder);
if (fs.existsSync(srcPath)) {
await fs.ensureSymlink(srcPath, destPath);
}
}
console.log('📋 Copying source code...');
for (const folder of foldersToCopy) {
const srcPath = path.join(PROJECT_ROOT, folder);
const destPath = path.join(TEMP_DIR, folder);
if (fs.existsSync(srcPath)) {
await fs.copy(srcPath, destPath);
}
}
console.log('📄 Copying configuration files...');
const allFiles = await fs.readdir(PROJECT_ROOT);
const envFiles = allFiles.filter((f) => f.startsWith('.env'));
const files = [...filesToCopy, ...envFiles];
for (const file of files) {
const srcPath = path.join(PROJECT_ROOT, file);
const destPath = path.join(TEMP_DIR, file);
if (fs.existsSync(srcPath)) {
await fs.copy(srcPath, destPath);
}
}
console.log('✂️ Pruning desktop-incompatible code...');
const relativeTempSrc = path.relative(PROJECT_ROOT, path.join(TEMP_DIR, 'src'));
await runPrebuild(relativeTempSrc);
await modifySourceForElectron(TEMP_DIR);
console.log('🏗 Running next build in shadow workspace...');
try {
execSync('next build --webpack', {
cwd: TEMP_DIR,
env: {
...process.env,
NODE_OPTIONS: process.env.NODE_OPTIONS || '--max-old-space-size=6144',
},
stdio: 'inherit',
});
console.log('📦 Extracting build artifacts...');
const sourceOutDir = path.join(TEMP_DIR, 'out');
const targetOutDir = path.join(PROJECT_ROOT, 'out');
// Clean up target directories
if (fs.existsSync(targetOutDir)) {
await fs.remove(targetOutDir);
}
if (fs.existsSync(sourceOutDir)) {
console.log('📦 Moving "out" directory...');
await fs.move(sourceOutDir, targetOutDir);
} else {
console.warn("⚠️ 'out' directory not found. Using '.next' instead (fallback)?");
const sourceNextDir = path.join(TEMP_DIR, '.next');
const targetNextDir = path.join(PROJECT_ROOT, '.next');
if (fs.existsSync(targetNextDir)) {
await fs.remove(targetNextDir);
}
if (fs.existsSync(sourceNextDir)) {
await fs.move(sourceNextDir, targetNextDir);
}
}
console.log('✅ Build completed successfully!');
} catch (error) {
console.error('❌ Build failed.');
throw error;
} finally {
console.log('🧹 Cleaning up workspace...');
await fs.remove(TEMP_DIR);
}
};
await build().catch((err) => {
console.error(err);
throw err;
});

View File

@@ -0,0 +1,394 @@
import { Lang, parse } from '@ast-grep/napi';
import fs from 'fs-extra';
import path from 'node:path';
import { isDirectRun, runStandalone } from './utils.mjs';
const rewriteFile = async (filePath: string, transformer: (code: string) => string) => {
if (!fs.existsSync(filePath)) return;
const original = await fs.readFile(filePath, 'utf8');
const updated = transformer(original);
if (updated !== original) {
await fs.writeFile(filePath, updated);
}
};
const desktopOnlyVariantsPage = `import { DynamicLayoutProps } from '@/types/next';
import DesktopRouter from './router';
export default async (_props: DynamicLayoutProps) => {
return <DesktopRouter />;
};
`;
const stripDevPanel = (code: string) => {
let result = code.replace(/import DevPanel from ['"]@\/features\/DevPanel['"];\r?\n?/, '');
result = result.replace(
/[\t ]*{process\.env\.NODE_ENV === 'development' && <DevPanel \/>}\s*\r?\n?/,
'',
);
return result;
};
const removeSecurityTab = (code: string) => {
const componentEntryRegex =
/[\t ]*\[SettingsTabs\.Security]: dynamic\(\(\) => import\('\.\.\/security'\), {[\s\S]+?}\),\s*\r?\n/;
const securityTabRegex = /[\t ]*SettingsTabs\.Security,\s*\r?\n/;
return code.replace(componentEntryRegex, '').replace(securityTabRegex, '');
};
const removeSpeedInsightsAndAnalytics = (code: string) => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const edits: Array<{ start: number; end: number; text: string }> = [];
// Remove SpeedInsights import
const speedInsightsImport = root.find({
rule: {
pattern: 'import { SpeedInsights } from $SOURCE',
},
});
if (speedInsightsImport) {
const range = speedInsightsImport.range();
edits.push({ start: range.start.index, end: range.end.index, text: '' });
}
// Remove Analytics import
const analyticsImport = root.find({
rule: {
pattern: 'import Analytics from $SOURCE',
},
});
if (analyticsImport) {
const range = analyticsImport.range();
edits.push({ start: range.start.index, end: range.end.index, text: '' });
}
// Remove Suspense block containing Analytics and SpeedInsights
// Find all Suspense blocks and check which one contains Analytics or SpeedInsights
const allSuspenseBlocks = root.findAll({
rule: {
pattern: '<Suspense fallback={null}>$$$</Suspense>',
},
});
for (const suspenseBlock of allSuspenseBlocks) {
const hasAnalytics = suspenseBlock.find({
rule: {
pattern: '<Analytics />',
},
});
const hasSpeedInsights = suspenseBlock.find({
rule: {
pattern: '<SpeedInsights />',
},
});
if (hasAnalytics || hasSpeedInsights) {
const range = suspenseBlock.range();
edits.push({ start: range.start.index, end: range.end.index, text: '' });
break; // Only remove the first matching Suspense block
}
}
// Remove inVercel variable if it's no longer used
const inVercelVar = root.find({
rule: {
pattern: 'const inVercel = process.env.VERCEL === "1";',
},
});
if (inVercelVar) {
// Check if inVercel is still used elsewhere
const allInVercelUsages = root.findAll({
rule: {
regex: 'inVercel',
},
});
// If only the declaration remains, remove it
if (allInVercelUsages.length === 1) {
const range = inVercelVar.range();
edits.push({ start: range.start.index, end: range.end.index, text: '' });
}
}
// Apply edits
if (edits.length === 0) return code;
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
};
const removeClerkLogic = (code: string) => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const edits: Array<{ start: number; end: number; text: string }> = [];
// Remove Clerk import - try multiple patterns
const clerkImportPatterns = [
{ pattern: 'import Clerk from $SOURCE' },
{ pattern: "import Clerk from './Clerk'" },
{ pattern: "import Clerk from './Clerk/index'" },
];
for (const pattern of clerkImportPatterns) {
const clerkImport = root.find({
rule: pattern,
});
if (clerkImport) {
const range = clerkImport.range();
edits.push({ start: range.start.index, end: range.end.index, text: '' });
break;
}
}
const findClerkIfStatement = () => {
const directMatch = root.find({
rule: {
pattern: 'if (authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH) { $$$ }',
},
});
if (directMatch) return directMatch;
const allIfStatements = root.findAll({
rule: {
kind: 'if_statement',
},
});
for (const ifStmt of allIfStatements) {
const condition = ifStmt.find({
rule: {
pattern: 'authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH',
},
});
if (condition) return ifStmt;
}
return null;
};
const clerkIfStatement = findClerkIfStatement();
if (clerkIfStatement) {
const ifRange = clerkIfStatement.range();
const elseClause = clerkIfStatement.find({
rule: {
kind: 'else_clause',
},
});
if (elseClause) {
const elseIfStmt = elseClause.find({
rule: {
kind: 'if_statement',
},
});
if (elseIfStmt) {
// Promote the first else-if to a top-level if and keep the rest of the chain
const elseRange = elseClause.range();
const replacement = code
.slice(elseRange.start.index, elseRange.end.index)
.replace(/^\s*else\s+/, '');
edits.push({
start: ifRange.start.index,
end: ifRange.end.index,
text: replacement,
});
} else {
const elseBlock = elseClause.find({
rule: {
kind: 'statement_block',
},
});
if (elseBlock) {
edits.push({
start: ifRange.start.index,
end: ifRange.end.index,
text: code.slice(elseBlock.range().start.index, elseBlock.range().end.index),
});
} else {
edits.push({ start: ifRange.start.index, end: ifRange.end.index, text: '' });
}
}
} else {
edits.push({ start: ifRange.start.index, end: ifRange.end.index, text: '' });
}
}
// Apply edits
if (edits.length === 0) return code;
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
};
const removeManifestFromMetadata = (code: string) => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const edits: Array<{ start: number; end: number; text: string }> = [];
// Find generateMetadata function
const generateMetadataFunc = root.find({
rule: {
pattern: 'export const generateMetadata = async ($$$) => { $$$ }',
},
});
if (!generateMetadataFunc) return code;
// Find return statement
const returnStatement = generateMetadataFunc.find({
rule: {
kind: 'return_statement',
},
});
if (!returnStatement) return code;
// Find the object in return statement
const returnObject = returnStatement.find({
rule: {
kind: 'object',
},
});
if (!returnObject) return code;
// Find all pair nodes (key-value pairs in the object)
const allPairs = returnObject.findAll({
rule: {
kind: 'pair',
},
});
const keysToRemove = ['manifest', 'metadataBase'];
for (const pair of allPairs) {
// Find the property_identifier or identifier
const key = pair.find({
rule: {
any: [{ kind: 'property_identifier' }, { kind: 'identifier' }],
},
});
if (key && keysToRemove.includes(key.text())) {
const range = pair.range();
// Include the trailing comma if present
const afterPair = code.slice(range.end.index, range.end.index + 10);
const commaMatch = afterPair.match(/^,\s*/);
const endIndex = commaMatch ? range.end.index + commaMatch[0].length : range.end.index;
edits.push({
start: range.start.index,
end: endIndex,
text: '',
});
}
}
// Apply edits
if (edits.length === 0) return code;
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
};
export const modifyAppCode = async (TEMP_DIR: string) => {
// 1. Replace src/app/[variants]/page.tsx with a desktop-only entry
const variantsPagePath = path.join(TEMP_DIR, 'src/app/[variants]/page.tsx');
if (fs.existsSync(variantsPagePath)) {
console.log(' Processing src/app/[variants]/page.tsx...');
await fs.writeFile(variantsPagePath, desktopOnlyVariantsPage);
}
// 2. Remove DevPanel from src/layout/GlobalProvider/index.tsx
const globalProviderPath = path.join(TEMP_DIR, 'src/layout/GlobalProvider/index.tsx');
if (fs.existsSync(globalProviderPath)) {
console.log(' Processing src/layout/GlobalProvider/index.tsx...');
await rewriteFile(globalProviderPath, stripDevPanel);
}
// 3. Delete src/app/[variants]/(main)/settings/security directory
const securityDirPath = path.join(TEMP_DIR, 'src/app/[variants]/(main)/settings/security');
if (fs.existsSync(securityDirPath)) {
console.log(' Deleting src/app/[variants]/(main)/settings/security directory...');
await fs.remove(securityDirPath);
}
// 4. Remove Security tab wiring from SettingsContent
const settingsContentPath = path.join(
TEMP_DIR,
'src/app/[variants]/(main)/settings/features/SettingsContent.tsx',
);
if (fs.existsSync(settingsContentPath)) {
console.log(' Processing src/app/[variants]/(main)/settings/features/SettingsContent.tsx...');
await rewriteFile(settingsContentPath, removeSecurityTab);
}
// 5. Remove SpeedInsights and Analytics from src/app/[variants]/layout.tsx
const variantsLayoutPath = path.join(TEMP_DIR, 'src/app/[variants]/layout.tsx');
if (fs.existsSync(variantsLayoutPath)) {
console.log(' Processing src/app/[variants]/layout.tsx...');
await rewriteFile(variantsLayoutPath, removeSpeedInsightsAndAnalytics);
}
// 6. Remove Clerk logic from src/layout/AuthProvider/index.tsx
const authProviderPath = path.join(TEMP_DIR, 'src/layout/AuthProvider/index.tsx');
if (fs.existsSync(authProviderPath)) {
console.log(' Processing src/layout/AuthProvider/index.tsx...');
await rewriteFile(authProviderPath, removeClerkLogic);
}
// 7. Replace mdx Image component with next/image export
const mdxImagePath = path.join(TEMP_DIR, 'src/components/mdx/Image.tsx');
if (fs.existsSync(mdxImagePath)) {
console.log(' Processing src/components/mdx/Image.tsx...');
await fs.writeFile(mdxImagePath, "export { default } from 'next/image';\n");
}
// 8. Remove manifest from metadata
const metadataPath = path.join(TEMP_DIR, 'src/app/[variants]/metadata.ts');
if (fs.existsSync(metadataPath)) {
console.log(' Processing src/app/[variants]/metadata.ts...');
await rewriteFile(metadataPath, removeManifestFromMetadata);
}
};
if (isDirectRun(import.meta.url)) {
await runStandalone('modifyAppCode', modifyAppCode, [
{ lang: Lang.Tsx, path: 'src/app/[variants]/page.tsx' },
{ lang: Lang.Tsx, path: 'src/layout/GlobalProvider/index.tsx' },
{ lang: Lang.Tsx, path: 'src/app/[variants]/(main)/settings/features/SettingsContent.tsx' },
{ lang: Lang.Tsx, path: 'src/app/[variants]/layout.tsx' },
{ lang: Lang.Tsx, path: 'src/layout/AuthProvider/index.tsx' },
{ lang: Lang.Tsx, path: 'src/components/mdx/Image.tsx' },
{ lang: Lang.Tsx, path: 'src/app/[variants]/metadata.ts' },
]);
}

View File

@@ -0,0 +1,61 @@
import { Lang, parse } from '@ast-grep/napi';
import fs from 'fs-extra';
import path from 'node:path';
import { isDirectRun, runStandalone } from './utils.mjs';
export const cleanUpCode = async (TEMP_DIR: string) => {
// Remove 'use server'
const filesToRemoveUseServer = [
'src/components/mdx/Image.tsx',
'src/features/DevPanel/CacheViewer/getCacheEntries.ts',
'src/server/translation.ts',
];
for (const file of filesToRemoveUseServer) {
const filePath = path.join(TEMP_DIR, file);
if (fs.existsSync(filePath)) {
console.log(` Processing ${file}...`);
const code = await fs.readFile(filePath, 'utf8');
const ast = parse(Lang.TypeScript, code);
const root = ast.root();
// 'use server' is usually an expression statement at the top
// We look for the literal string 'use server' or "use server"
const useServer =
root.find({
rule: {
pattern: "'use server'",
},
}) ||
root.find({
rule: {
pattern: '"use server"',
},
});
if (useServer) {
// Find the statement containing this string
let curr = useServer.parent();
while (curr) {
if (curr.kind() === 'expression_statement') {
curr.replace('');
break;
}
if (curr.kind() === 'program') break;
curr = curr.parent();
}
}
await fs.writeFile(filePath, root.text());
}
}
};
if (isDirectRun(import.meta.url)) {
await runStandalone('cleanUpCode', cleanUpCode, [
{ lang: Lang.Tsx, path: 'src/components/mdx/Image.tsx' },
{ lang: Lang.TypeScript, path: 'src/features/DevPanel/CacheViewer/getCacheEntries.ts' },
{ lang: Lang.TypeScript, path: 'src/server/translation.ts' },
]);
}

View File

@@ -0,0 +1,27 @@
import { Lang } from '@ast-grep/napi';
import path from 'node:path';
import { modifyAppCode } from './appCode.mjs';
import { cleanUpCode } from './cleanUp.mjs';
import { modifyNextConfig } from './nextConfig.mjs';
import { modifyRoutes } from './routes.mjs';
import { isDirectRun, runStandalone } from './utils.mjs';
export const modifySourceForElectron = async (TEMP_DIR: string) => {
await modifyNextConfig(TEMP_DIR);
await modifyAppCode(TEMP_DIR);
await modifyRoutes(TEMP_DIR);
await cleanUpCode(TEMP_DIR);
};
if (isDirectRun(import.meta.url)) {
await runStandalone('modifySourceForElectron', modifySourceForElectron, [
{ lang: Lang.TypeScript, path: path.join(process.cwd(), 'next.config.ts') },
{ lang: Lang.Tsx, path: 'src/app/[variants]/page.tsx' },
{ lang: Lang.Tsx, path: 'src/layout/GlobalProvider/index.tsx' },
{ lang: Lang.Tsx, path: 'src/app/[variants]/router/desktopRouter.config.tsx' },
{ lang: Lang.Tsx, path: 'src/components/mdx/Image.tsx' },
{ lang: Lang.TypeScript, path: 'src/features/DevPanel/CacheViewer/getCacheEntries.ts' },
{ lang: Lang.TypeScript, path: 'src/server/translation.ts' },
]);
}

View File

@@ -0,0 +1,133 @@
import { Lang, parse } from '@ast-grep/napi';
import fs from 'fs-extra';
import path from 'node:path';
import { isDirectRun, runStandalone } from './utils.mjs';
interface Edit {
end: number;
start: number;
text: string;
}
export const modifyNextConfig = async (TEMP_DIR: string) => {
const nextConfigPath = path.join(TEMP_DIR, 'next.config.ts');
if (!fs.existsSync(nextConfigPath)) return;
console.log(' Processing next.config.ts...');
const code = await fs.readFile(nextConfigPath, 'utf8');
const ast = parse(Lang.TypeScript, code);
const root = ast.root();
const edits: Edit[] = [];
// Find nextConfig declaration
const nextConfigDecl = root.find({
rule: {
pattern: 'const nextConfig: NextConfig = { $$$ }',
},
});
if (nextConfigDecl) {
// 1. Remove redirects
const redirectsProp = nextConfigDecl.find({
rule: {
kind: 'property_identifier',
regex: '^redirects$',
},
});
if (redirectsProp) {
let curr = redirectsProp.parent();
while (curr) {
if (curr.kind() === 'pair') {
const range = curr.range();
edits.push({ end: range.end.index, start: range.start.index, text: '' });
break;
}
if (curr.kind() === 'object') break;
curr = curr.parent();
}
}
// 2. Remove headers
const headersProp = nextConfigDecl.find({
rule: {
kind: 'property_identifier',
regex: '^headers$',
},
});
if (headersProp) {
let curr = headersProp.parent();
while (curr) {
if (curr.kind() === 'pair' || curr.kind() === 'method_definition') {
const range = curr.range();
edits.push({ end: range.end.index, start: range.start.index, text: '' });
break;
}
if (curr.kind() === 'object') break;
curr = curr.parent();
}
}
// 3. Remove spread element
const spread = nextConfigDecl.find({
rule: {
kind: 'spread_element',
},
});
if (spread) {
const range = spread.range();
edits.push({ end: range.end.index, start: range.start.index, text: '' });
}
// 4. Inject output: 'export'
const objectNode = nextConfigDecl.find({
rule: { kind: 'object' },
});
if (objectNode) {
const range = objectNode.range();
// Insert after the opening brace `{
edits.push({
end: range.start.index + 1,
start: range.start.index + 1,
text: "\n output: 'export',",
});
}
}
// Remove withPWA wrapper
const withPWA = root.find({
rule: {
pattern: 'withPWA($A)',
},
});
if (withPWA) {
const inner = withPWA.getMatch('A');
if (inner) {
const range = withPWA.range();
edits.push({ end: range.end.index, start: range.start.index, text: inner.text() });
}
}
// Apply edits
edits.sort((a, b) => b.start - a.start);
let newCode = code;
for (const edit of edits) {
newCode = newCode.slice(0, edit.start) + edit.text + newCode.slice(edit.end);
}
// Cleanup commas (syntax fix)
// 1. Double commas ,, -> , (handle spaces/newlines between)
newCode = newCode.replaceAll(/,(\s*,)+/g, ',');
// 2. Leading comma in object { , -> {
newCode = newCode.replaceAll(/{\s*,/g, '{');
// 3. Trailing comma before closing brace is valid in JS/TS
await fs.writeFile(nextConfigPath, newCode);
};
if (isDirectRun(import.meta.url)) {
await runStandalone('modifyNextConfig', modifyNextConfig, [
{ lang: Lang.TypeScript, path: process.cwd() + '/next.config.ts' },
]);
}

View File

@@ -0,0 +1,90 @@
import { Lang, parse } from '@ast-grep/napi';
import fs from 'fs-extra';
import path from 'node:path';
import { isDirectRun, runStandalone } from './utils.mjs';
export const modifyRoutes = async (TEMP_DIR: string) => {
// 1. Delete routes
const filesToDelete = [
// Backend API routes
'src/app/(backend)/api',
'src/app/(backend)/webapi',
'src/app/(backend)/trpc',
'src/app/(backend)/oidc',
'src/app/(backend)/middleware',
'src/app/(backend)/f',
'src/app/(backend)/market',
// Auth & User routes
'src/app/[variants]/(auth)',
'src/app/[variants]/(main)/(mobile)/me',
'src/app/[variants]/(main)/changelog',
'src/app/[variants]/oauth',
// Other app roots
'src/app/market-auth-callback',
'src/app/manifest.ts',
'src/app/robots.tsx',
'src/app/sitemap.tsx',
'src/app/sw.ts',
// Config files
'src/instrumentation.ts',
'src/instrumentation.node.ts',
// Desktop specific routes
'src/app/desktop/devtools',
'src/app/desktop/layout.tsx',
];
for (const file of filesToDelete) {
const fullPath = path.join(TEMP_DIR, file);
await fs.remove(fullPath);
}
// 2. Modify desktopRouter.config.tsx
const routerConfigPath = path.join(
TEMP_DIR,
'src/app/[variants]/router/desktopRouter.config.tsx',
);
if (fs.existsSync(routerConfigPath)) {
console.log(' Processing src/app/[variants]/router/desktopRouter.config.tsx...');
const code = await fs.readFile(routerConfigPath, 'utf8');
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const changelogNode = root.find({
rule: {
pattern: "{ path: 'changelog', $$$ }",
},
});
if (changelogNode) {
changelogNode.replace('');
}
const changelogImport = root.find({
rule: {
pattern: "import('../(main)/changelog')",
},
});
if (changelogImport) {
// Find the closest object (route definition) and remove it
let curr = changelogImport.parent();
while (curr) {
if (curr.kind() === 'object') {
curr.replace('');
break;
}
curr = curr.parent();
}
}
await fs.writeFile(routerConfigPath, root.text());
}
};
if (isDirectRun(import.meta.url)) {
await runStandalone('modifyRoutes', modifyRoutes, [
{ lang: Lang.Tsx, path: 'src/app/[variants]/router/desktopRouter.config.tsx' },
]);
}

View File

@@ -0,0 +1,67 @@
import { Lang, parse } from '@ast-grep/napi';
import fs from 'fs-extra';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
interface ValidationTarget {
lang: Lang;
path: string;
}
export const isDirectRun = (importMetaUrl: string) => {
const entry = process.argv[1];
if (!entry) return false;
return importMetaUrl === pathToFileURL(entry).href;
};
export const resolveTempDir = () => {
const candidate = process.env.TEMP_DIR || process.argv[2];
const resolved = candidate
? path.resolve(candidate)
: path.resolve(process.cwd(), 'tmp', 'desktop-build');
if (!fs.existsSync(resolved)) {
throw new Error(`TEMP_DIR not found: ${resolved}`);
}
return resolved;
};
export const validateFiles = async (tempDir: string, targets: ValidationTarget[]) => {
for (const target of targets) {
const filePath = path.join(tempDir, target.path);
if (!fs.existsSync(filePath)) {
console.warn(` ⚠️ Skipped validation, missing file: ${target.path}`);
continue;
}
const code = await fs.readFile(filePath, 'utf8');
parse(target.lang, code);
console.log(` ✅ Validated: ${target.path}`);
}
};
export const runStandalone = async (
name: string,
modifier: (tempDir: string) => Promise<void>,
validateTargets: ValidationTarget[] = [],
) => {
try {
const workdir = process.cwd();
console.log(`▶️ Running ${name} with TEMP_DIR=${workdir}`);
await modifier(workdir);
if (validateTargets.length) {
console.log('🔎 Validating modified files...');
await validateFiles(workdir, validateTargets);
}
console.log(`${name} completed`);
} catch (error) {
console.error(`${name} failed`, error);
process.exitCode = 1;
}
};

View File

@@ -0,0 +1,18 @@
import fs from 'fs-extra';
import path from 'node:path';
const rootDir = path.resolve(__dirname, '../..');
const exportSourceDir = path.join(rootDir, 'out');
const exportTargetDir = path.join(rootDir, 'apps/desktop/dist/next');
if (fs.existsSync(exportSourceDir)) {
console.log(`📦 Copying Next export assets from ${exportSourceDir} to ${exportTargetDir}...`);
fs.ensureDirSync(exportTargetDir);
fs.copySync(exportSourceDir, exportTargetDir, { overwrite: true });
console.log(`✅ Export assets copied successfully!`);
} else {
console.log(` No Next export output found at ${exportSourceDir}, skipping copy.`);
}
console.log(`🎉 Export move completed!`);

View File

@@ -1,69 +0,0 @@
/* eslint-disable unicorn/no-process-exit */
import fs from 'fs-extra';
import { execSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
const rootDir = path.resolve(__dirname, '../..');
// 定义源目录和目标目录
const sourceDir: string = path.join(rootDir, '.next/standalone');
const targetDir: string = path.join(rootDir, 'apps/desktop/dist/next');
// 向 sourceDir 写入 .env 文件
const env = fs.readFileSync(path.join(rootDir, '.env.desktop'), 'utf8');
fs.writeFileSync(path.join(sourceDir, '.env'), env, 'utf8');
console.log(`⚓️ Inject .env successful`);
// 确保目标目录的父目录存在
fs.ensureDirSync(path.dirname(targetDir));
// 如果目标目录已存在,先删除它
if (fs.existsSync(targetDir)) {
console.log(`🗑️ Target directory ${targetDir} already exists, deleting...`);
try {
fs.removeSync(targetDir);
console.log(`✅ Old target directory removed successfully`);
} catch (error) {
console.warn(`⚠️ Failed to delete target directory: ${error}`);
console.log('🔄 Trying to delete using system command...');
try {
if (os.platform() === 'win32') {
execSync(`rmdir /S /Q "${targetDir}"`, { stdio: 'inherit' });
} else {
execSync(`rm -rf "${targetDir}"`, { stdio: 'inherit' });
}
console.log('✅ Successfully deleted old target directory');
} catch (cmdError) {
console.error(`❌ Unable to delete target directory, might need manual cleanup: ${cmdError}`);
}
}
}
console.log(`🚚 Moving ${sourceDir} to ${targetDir}...`);
try {
// 使用 fs-extra 的 move 方法
fs.moveSync(sourceDir, targetDir, { overwrite: true });
console.log(`✅ Directory moved successfully!`);
} catch (error) {
console.error('❌ fs-extra move failed:', error);
console.log('🔄 Trying to move using system command...');
try {
// 使用系统命令进行移动
if (os.platform() === 'win32') {
execSync(`move "${sourceDir}" "${targetDir}"`, { stdio: 'inherit' });
} else {
execSync(`mv "${sourceDir}" "${targetDir}"`, { stdio: 'inherit' });
}
console.log('✅ System command move completed successfully!');
} catch (mvError) {
console.error('❌ Failed to move directory:', mvError);
console.log('💡 Try running manually: sudo mv ' + sourceDir + ' ' + targetDir);
process.exit(1);
}
}
console.log(`🎉 Move completed!`);

View File

@@ -1,7 +1,7 @@
import { consola } from 'consola';
import { colors } from 'consola/utils';
import { unset } from 'es-toolkit/compat';
import { diff } from 'just-diff';
import { unset } from 'lodash';
import { existsSync } from 'node:fs';
import {

View File

@@ -1,11 +1,18 @@
import * as dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
dotenv.config();
if (isDesktop) {
dotenvExpand.expand(dotenv.config({ path: '.env.desktop' }));
dotenvExpand.expand(dotenv.config({ override: true, path: '.env.desktop.local' }));
} else {
dotenvExpand.expand(dotenv.config());
}
// 创建需要排除的特性映射
/* eslint-disable sort-keys-fix/sort-keys-fix */
const partialBuildPages = [
@@ -62,22 +69,24 @@ const partialBuildPages = [
/**
* 删除指定的目录
*/
const removeDirectories = async () => {
export const runPrebuild = async (targetDir: string = 'src') => {
// 遍历 partialBuildPages 数组
for (const page of partialBuildPages) {
// 检查是否需要禁用该功能
if (page.disabled) {
for (const dirPath of page.paths) {
const fullPath = path.resolve(process.cwd(), dirPath);
// Replace 'src' with targetDir
const relativePath = dirPath.replace(/^src/, targetDir);
const fullPath = path.resolve(process.cwd(), relativePath);
// 检查目录是否存在
if (existsSync(fullPath)) {
try {
// 递归删除目录
await rm(fullPath, { force: true, recursive: true });
console.log(`♻️ Removed ${dirPath} successfully`);
console.log(`♻️ Removed ${relativePath} successfully`);
} catch (error) {
console.error(`Failed to remove directory ${dirPath}:`, error);
console.error(`Failed to remove directory ${relativePath}:`, error);
}
}
}
@@ -85,7 +94,12 @@ const removeDirectories = async () => {
}
};
// 执行删除操作
console.log('Starting prebuild cleanup...');
await removeDirectories();
console.log('Prebuild cleanup completed.');
// Check if the script is being run directly
const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
if (isMainModule) {
// 执行删除操作
console.log('Starting prebuild cleanup...');
await runPrebuild();
console.log('Prebuild cleanup completed.');
}

View File

@@ -1,4 +1,4 @@
import { kebabCase } from 'lodash';
import { kebabCase } from 'es-toolkit/compat';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';

View File

@@ -0,0 +1,29 @@
/**
* Desktop env preloader for Next.js.
*
* Why: Next.js only auto-loads `.env*` (e.g. `.env`, `.env.local`, `.env.development`),
* but our desktop build expects `.env.desktop`.
*
* This file is intended to be used via Node's `-r` (require) flag so it runs
* BEFORE Next.js loads its own env config:
*
* node -r ./scripts/registerDesktopEnv.cjs ./node_modules/next/dist/bin/next build
*/
const path = require('node:path');
const dotenv = require('dotenv');
const dotenvExpand = require('dotenv-expand');
const fs = require('node:fs');
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
if (isDesktop) {
const cwd = process.cwd();
const envDesktop = path.join(cwd, '.env.desktop');
const envDesktopLocal = path.join(cwd, '.env.desktop.local');
if (fs.existsSync(envDesktop)) dotenvExpand.expand(dotenv.config({ path: envDesktop }));
if (fs.existsSync(envDesktopLocal))
dotenvExpand.expand(dotenv.config({ override: true, path: envDesktopLocal }));
}

View File

@@ -0,0 +1,251 @@
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
import { join, relative } from 'node:path';
interface ReplaceConfig {
/** 要替换的组件列表 */
components: string[];
/** 是否为 dry-run 模式(仅预览,不实际修改) */
dryRun?: boolean;
/** 文件扩展名白名单 */
fileExtensions?: string[];
/** 原始包名 */
fromPackage: string;
/** 要扫描的目录 */
targetDir: string;
/** 目标包名 */
toPackage: string;
}
/**
* 递归获取目录下所有文件
*/
function getAllFiles(dir: string, extensions: string[]): string[] {
const files: string[] = [];
function walk(currentPath: string) {
const items = readdirSync(currentPath);
for (const item of items) {
const fullPath = join(currentPath, item);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
// 跳过 node_modules 等目录
if (!['node_modules', '.git', 'dist', 'build', '.next'].includes(item)) {
walk(fullPath);
}
} else if (stat.isFile()) {
const hasValidExtension = extensions.some((ext) => fullPath.endsWith(ext));
if (hasValidExtension) {
files.push(fullPath);
}
}
}
}
walk(dir);
return files;
}
/**
* 解析 import 语句,提取导入的组件
*/
function parseImportStatement(line: string, packageName: string) {
// 匹配 import { ... } from 'package'
const importRegex = new RegExp(
`import\\s+{([^}]+)}\\s+from\\s+['"]${packageName.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&')}['"]`,
);
const match = line.match(importRegex);
if (!match) return null;
const importContent = match[1];
const components = importContent
.split(',')
.map((item) => {
const trimmed = item.trim();
// 处理 as 别名: ComponentName as AliasName
const asMatch = trimmed.match(/^(\w+)(?:\s+as\s+(\w+))?/);
return asMatch
? {
alias: asMatch[2] || null,
name: asMatch[1],
raw: trimmed,
}
: null;
})
.filter(Boolean) as Array<{ alias: string | null; name: string; raw: string }>;
return {
components,
fullMatch: match[0],
indentation: line.match(/^\s*/)?.[0] || '',
};
}
/**
* 替换文件中的 import 语句
*/
function replaceImportsInFile(filePath: string, config: ReplaceConfig): boolean {
const content = readFileSync(filePath, 'utf8');
const lines = content.split('\n');
let modified = false;
const newLines: string[] = [];
for (const line of lines) {
const parsed = parseImportStatement(line, config.fromPackage);
if (!parsed) {
newLines.push(line);
continue;
}
// 找出需要替换的组件和保留的组件
const toReplace = parsed.components.filter((comp) => config.components.includes(comp.name));
const toKeep = parsed.components.filter((comp) => !config.components.includes(comp.name));
if (toReplace.length === 0) {
// 没有需要替换的组件
newLines.push(line);
continue;
}
modified = true;
// 生成新的 import 语句
const { indentation } = parsed;
// 如果有保留的组件,保留原来的 import
if (toKeep.length > 0) {
const keepImports = toKeep.map((c) => c.raw).join(', ');
newLines.push(`${indentation}import { ${keepImports} } from '${config.fromPackage}';`);
}
// 添加新的 import
const replaceImports = toReplace.map((c) => c.raw).join(', ');
newLines.push(`${indentation}import { ${replaceImports} } from '${config.toPackage}';`);
}
if (modified) {
const newContent = newLines.join('\n');
if (!config.dryRun) {
writeFileSync(filePath, newContent, 'utf8');
}
return true;
}
return false;
}
/**
* 执行替换
*/
function executeReplace(config: ReplaceConfig) {
const extensions = config.fileExtensions || ['.ts', '.tsx', '.js', '.jsx'];
const files = getAllFiles(config.targetDir, extensions);
console.log(`\n🔍 扫描目录: ${config.targetDir}`);
console.log(`📦 从 "${config.fromPackage}" 替换到 "${config.toPackage}"`);
console.log(`🎯 目标组件: ${config.components.join(', ')}`);
console.log(`📄 找到 ${files.length} 个文件\n`);
if (config.dryRun) {
console.log('🔔 [DRY RUN 模式] 仅预览,不会实际修改文件\n');
}
let modifiedCount = 0;
const modifiedFiles: string[] = [];
for (const file of files) {
const wasModified = replaceImportsInFile(file, config);
if (wasModified) {
modifiedCount++;
modifiedFiles.push(relative(process.cwd(), file));
}
}
console.log('\n✅ 完成!');
console.log(`📝 修改了 ${modifiedCount} 个文件\n`);
if (modifiedFiles.length > 0) {
console.log('修改的文件:');
for (const file of modifiedFiles) {
console.log(` - ${file}`);
}
}
}
// ============ 主函数 ============
/**
* 从命令行参数解析配置
*/
function parseArgs(): ReplaceConfig | null {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
console.log(`
使用方法:
bun run scripts/replaceComponentImports.ts [选项]
选项:
--components <comp1,comp2,...> 要替换的组件列表(逗号分隔)
--from <package> 原始包名
--to <package> 目标包名
--dir <directory> 要扫描的目录(默认: src
--ext <.ext1,.ext2,...> 文件扩展名(默认: .ts,.tsx,.js,.jsx
--dry-run 仅预览,不实际修改文件
--help, -h 显示帮助信息
示例:
# 将 antd 的 Skeleton 和 Empty 替换为 @lobehub/ui
bun run scripts/replaceComponentImports.ts \\
--components Skeleton,Empty \\
--from antd \\
--to @lobehub/ui \\
--dir src
# 仅预览,不修改
bun run scripts/replaceComponentImports.ts \\
--components Skeleton,Empty \\
--from antd \\
--to @lobehub/ui \\
--dry-run
`);
return null;
}
const getArgValue = (flag: string): string | undefined => {
const index = args.indexOf(flag);
return index !== -1 && index + 1 < args.length ? args[index + 1] : undefined;
};
const componentsStr = getArgValue('--components');
const fromPackage = getArgValue('--from');
const toPackage = getArgValue('--to');
const targetDir = getArgValue('--dir') || 'src';
const extStr = getArgValue('--ext');
const dryRun = args.includes('--dry-run');
if (!componentsStr || !fromPackage || !toPackage) {
console.error('❌ 错误: 必须指定 --components, --from 和 --to 参数');
console.error('使用 --help 查看帮助信息');
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
}
return {
components: componentsStr.split(',').map((c) => c.trim()),
dryRun,
fileExtensions: extStr ? extStr.split(',').map((e) => e.trim()) : undefined,
fromPackage,
targetDir,
toPackage,
};
}
// 执行脚本
const config = parseArgs();
if (config) {
executeReplace(config);
}

View File

@@ -0,0 +1,33 @@
import * as dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
if (isDesktop) {
const envDesktop = path.resolve(process.cwd(), '.env.desktop');
const envDesktopLocal = path.resolve(process.cwd(), '.env.desktop.local');
if (existsSync(envDesktop)) dotenvExpand.expand(dotenv.config({ path: envDesktop }));
if (existsSync(envDesktopLocal))
dotenvExpand.expand(dotenv.config({ override: true, path: envDesktopLocal }));
}
const nextBin = path.resolve(process.cwd(), 'node_modules', 'next', 'dist', 'bin', 'next');
const args = process.argv.slice(2);
const child = spawn(process.execPath, [nextBin, ...args], {
env: process.env,
stdio: 'inherit',
});
child.on('exit', (code, signal) => {
if (typeof code === 'number') {
process.exitCode = code;
return;
}
process.exitCode = signal ? 1 : 0;
});