Skip to content

Commit 31040a1

Browse files
authored
feat: Xcode multi-platform template with deploy & screenshot scripts (#129)
* feat: add Xcode multi-platform template with deploy and screenshot scripts New "Xcode Multi-Platform" app template scaffolds iOS + macOS + watchOS via XcodeGen with generic deploy.sh (TestFlight), take_screenshots.sh (iOS/iPad), and take_screenshots_macos.sh (macOS) automation scripts. PortOS now detects missing management scripts in Xcode-managed apps and surfaces a banner in the app detail overview with one-click install. Deploy panel adds --watch (watchOS) flag support. * address review: fix Xcode template scheme/script issues and add endpoint tests - Add UITests to iOS scheme testTargets for screenshot automation - Fix watchOS scheme name mismatch (use underscore to match target name) - Replace shell exec with execFile for chmod (avoid injection) - Add win32 platform guard to deriveProjectInfo - Surface partial install errors in OverviewTab toast notifications - Update deployApp JSDoc to include --watch flag - Add 5 tests for POST /api/apps/:id/xcode-scripts/install endpoint * address review: restrict script checks to Xcode types, add win32 chmod guard, reuse toTargetName - Replace NON_PM2_TYPES with XCODE_TYPES set (excludes 'swift'/SPM apps) - Add win32 platform guard for chmod in installScripts - Reuse shared toTargetName() in scaffold.js instead of inline regex * address review: scope deploy tests to iOS builds, use fs.chmod in scaffold - Only run iOS simulator tests when --ios is being built (not for macOS/watchOS-only deploys) - Replace shell exec chmod with fs.promises.chmod in scaffoldXcode.js * address review: consistent xcodeScripts in API response, safer bundle ID derivation - Add xcodeScripts to PM2 app enrichment for consistent response shape - Derive bundle ID from project name instead of regex match (avoids picking wrong target's PRODUCT_BUNDLE_IDENTIFIER in multi-target configs) * address review: expand tilde in deploy script KEY_PATH Tilde (~) doesn't expand inside double quotes in bash, so the generated deploy.sh now uses parameter substitution to replace leading ~ with $HOME before the -f check. * address review: fix Swift compilation, dynamic simulator detection, dedupe toasts - Add missing attributes: nil to FileManager.createDirectory call - Use sanitized targetName for Swift appName constant - Detect iOS runtime version dynamically instead of hard-coding 18.6 - Remove pinned OS version from deploy.sh test fallback - Fix duplicate error toasts by letting request() handle HTTP errors * address review: use shared XCODE_BUNDLE_PREFIX in iOS scaffold, parse actual bundle ID - Import XCODE_BUNDLE_PREFIX in scaffoldIOS instead of hard-coding - deriveProjectInfo now parses PRODUCT_BUNDLE_IDENTIFIER from project.yml, filtering out test/watch targets, with fallback to name-derived ID * address review: toBundleId fallback, quoted YAML handling, chmod error recovery, service tests - toBundleId falls back to 'app' suffix for names with no alphanumeric chars - deriveProjectInfo strips YAML wrapping quotes from scalar values - chmod failure pushed to errors array instead of throwing 500 - Add 20 unit tests for xcodeScripts service (toBundleId, toTargetName, checkScripts, generateDeployScript, generateScreenshotScript, etc.) * address review: validate repoPath existence in checkScripts and install route - checkScripts short-circuits when repoPath doesn't exist on disk - Install route returns PATH_NOT_FOUND (400) for missing repo paths - Add test for PATH_NOT_FOUND case, fix test mocks for repoPath check * address review: validate project.yml values against strict allowlists Parsed target names and bundle IDs from project.yml are now validated against safe character patterns before being interpolated into generated bash scripts. Falls back to sanitized toTargetName/toBundleId on invalid input. * address review: guard git commit in deploy.sh, skip chmod on win32 in scaffold - deploy.sh git commit only runs inside git repos with staged changes - scaffoldXcode skips chmod on Windows (consistent with installScripts) * address review: fix bash tilde expansion bug and expand installScripts test coverage - Generated deploy.sh used \~ instead of ~ in KEY_PATH parameter expansion, which only matched a literal backslash-tilde and left ~/Library/... paths unexpanded, causing the subsequent -f file check to fail. - Tightened the existing tilde expansion test to assert the exact pattern (KEY_PATH/#~/$HOME) so this regression cannot reappear silently. - Added unit tests for installScripts and deriveProjectInfo covering: project.yml parsing (name + bundle id, quote stripping, watchkit/Tests filtering, unsafe-name rejection), .xcodeproj fallback, win32 short-circuit, the never-overwrite guarantee, .env.example creation guard, chmod failure reporting, and the Windows chmod-not-supported message. * address review: restore process.platform descriptor cleanly in win32 test Capture the original property descriptor via getOwnPropertyDescriptor and re-define with configurable: true so the override can be reverted on Node versions where process.platform is not configurable by default. * address review: scope Xcode scripts by app type and validate via Zod enum - XCODE_MANAGEMENT_SCRIPTS entries now declare which app.types they apply to. ios-native apps no longer surface take_screenshots_macos.sh in the missing- scripts banner, and macos-native apps no longer surface take_screenshots.sh. - installScripts refuses to write a script that does not apply to the target app's type and reports a clear error. - Added scriptsForAppType helper and exported XCODE_SCRIPT_NAMES. - Tightened POST /api/apps/:id/xcode-scripts/install validation to a Zod enum bounded by XCODE_SCRIPT_NAMES, so unknown or oversized payloads are rejected at the validation layer instead of bouncing through installScripts. - Added unit tests covering type-scoped checkScripts/installScripts behavior and updated route tests for the new validation contract. * address review: harden toTargetName and quote .env example with spaces - toTargetName now produces a guaranteed-valid Swift identifier: trims whitespace, collapses runs of underscores, strips leading/trailing underscores, prefixes with 'App_' when the first character is not [A-Za-z_], and falls back to 'App' for empty/null/undefined input. This prevents the scaffolder from emitting uncompilable Swift like 'struct 123App' when the user names an app starting with a digit. - Quoted APPSTORE_API_PRIVATE_KEY_PATH in XCODE_ENV_EXAMPLE because deploy.sh loads .env via 'source .env' and the example path contains spaces; without quotes the shell would split on whitespace and break sourcing. - Added regression tests for digit-leading names, all-symbol names, runs of symbols collapsing, leading/trailing trimming, and null/undefined input.
1 parent f883273 commit 31040a1

13 files changed

Lines changed: 2206 additions & 20 deletions

File tree

.changelog/NEXT.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
## Added
2+
3+
- **Xcode Multi-Platform template** — new "Xcode Multi-Platform" app template scaffolds a SwiftUI project with iOS, macOS, and watchOS targets via XcodeGen
4+
- Generic `deploy.sh` with `--ios`, `--macos`, `--watch`, `--all` flags for TestFlight deployment
5+
- Generic `take_screenshots.sh` and `take_screenshots_macos.sh` for App Store screenshot automation
6+
- UI test target with `ScreenshotTests.swift` stubs for XCUITest-based screenshot capture
7+
- Shared module for cross-platform code, macOS entitlements, watchOS companion app
8+
- **Xcode script health check** — PortOS now detects missing management scripts (deploy, screenshots) in Xcode-managed apps and surfaces a banner in the app detail overview with one-click install
9+
- Deploy panel now includes `--watch` (watchOS) flag option
10+
111
## Fixed
212

313
- Submodule status API (`/api/git/submodules/status`) always returned empty array — `stdout.trim()` was stripping the leading space status character from `git submodule status` output, causing the regex parser to fail

client/src/components/apps/DeployPanel.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ import { useAppDeploy } from '../../hooks/useAppDeploy';
66
const FLAG_OPTIONS = [
77
{ value: '--ios', label: 'iOS' },
88
{ value: '--macos', label: 'macOS' },
9+
{ value: '--watch', label: 'watchOS' },
910
{ value: '--all', label: 'All Platforms' },
1011
{ value: '--skip-tests', label: 'Skip Tests' },
1112
];
1213

13-
const PLATFORM_FLAGS = new Set(['--ios', '--macos', '--all']);
14+
const PLATFORM_FLAGS = new Set(['--ios', '--macos', '--watch', '--all']);
1415

1516
export default function DeployPanel({ appId, appName }) {
1617
const { output, isDeploying, error, result, startDeploy, clearDeploy } = useAppDeploy();

client/src/components/apps/tabs/OverviewTab.jsx

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect, useMemo } from 'react';
2-
import { FolderOpen, Terminal, Code, RefreshCw, Wrench, Archive, ArchiveRestore, Ticket, Download, Tag } from 'lucide-react';
2+
import { FolderOpen, Terminal, Code, RefreshCw, Wrench, Archive, ArchiveRestore, Ticket, Download, Tag, AlertTriangle, Rocket, Camera } from 'lucide-react';
33
import toast from '../../ui/Toast';
44
import { NON_PM2_TYPES } from '../constants';
55
import BrailleSpinner from '../../BrailleSpinner';
@@ -10,12 +10,19 @@ import SlashDoPanel from '../SlashDoPanel';
1010
import { useAppOperation } from '../../../hooks/useAppOperation';
1111
import * as api from '../../../services/api';
1212

13+
const SCRIPT_ICONS = {
14+
'deploy.sh': Rocket,
15+
'take_screenshots.sh': Camera,
16+
'take_screenshots_macos.sh': Camera
17+
};
18+
1319
export default function OverviewTab({ app, onRefresh }) {
1420
const [editingApp, setEditingApp] = useState(null);
1521
const [refreshingConfig, setRefreshingConfig] = useState(false);
1622
const [archiving, setArchiving] = useState(false);
1723
const [jiraTickets, setJiraTickets] = useState(null);
1824
const [loadingTickets, setLoadingTickets] = useState(false);
25+
const [installingScripts, setInstallingScripts] = useState(false);
1926

2027
const onComplete = useMemo(() => () => onRefresh(), [onRefresh]);
2128
const { steps, isOperating, operationType, error, completed, startUpdate, startStandardize } = useAppOperation({ onComplete });
@@ -43,6 +50,24 @@ export default function OverviewTab({ app, onRefresh }) {
4350

4451
const handleStandardize = () => startStandardize(app.id);
4552

53+
const missingScripts = app.xcodeScripts?.missing || [];
54+
55+
const handleInstallScripts = async (scriptNames) => {
56+
setInstallingScripts(true);
57+
const result = await api.installXcodeScripts(app.id, scriptNames).catch(() => null);
58+
setInstallingScripts(false);
59+
if (!result) return; // request() already showed error toast
60+
61+
if (result.installed?.length) {
62+
toast.success(`Installed: ${result.installed.join(', ')}`);
63+
onRefresh();
64+
}
65+
66+
if (result.errors?.length) {
67+
toast.error(`Some scripts could not be installed: ${result.errors.join(', ')}`);
68+
}
69+
};
70+
4671
const handleArchive = async () => {
4772
setArchiving(true);
4873
await api.archiveApp(app.id).catch(() => null);
@@ -90,6 +115,51 @@ export default function OverviewTab({ app, onRefresh }) {
90115
)}
91116
</div>
92117

118+
{/* Missing Xcode Scripts Banner */}
119+
{missingScripts.length > 0 && (
120+
<div className="bg-port-warning/10 border border-port-warning/30 rounded-lg p-4">
121+
<div className="flex items-start gap-3">
122+
<AlertTriangle size={18} className="text-port-warning shrink-0 mt-0.5" />
123+
<div className="flex-1">
124+
<div className="text-sm font-medium text-port-warning mb-2">
125+
Missing management scripts
126+
</div>
127+
<div className="flex flex-wrap gap-2 mb-3">
128+
{missingScripts.map(s => {
129+
const Icon = SCRIPT_ICONS[s.name] || Terminal;
130+
return (
131+
<span key={s.name} className="inline-flex items-center gap-1.5 px-2 py-1 bg-port-card border border-port-border rounded text-xs text-gray-300">
132+
<Icon size={12} />
133+
<span className="font-mono">{s.name}</span>
134+
<span className="text-gray-500">{s.description}</span>
135+
</span>
136+
);
137+
})}
138+
</div>
139+
<div className="flex gap-2">
140+
<button
141+
onClick={() => handleInstallScripts(missingScripts.map(s => s.name))}
142+
disabled={installingScripts}
143+
className="px-3 py-1.5 bg-port-warning/20 text-port-warning hover:bg-port-warning/30 rounded text-xs font-medium disabled:opacity-50"
144+
>
145+
{installingScripts ? 'Installing...' : 'Install All'}
146+
</button>
147+
{missingScripts.length > 1 && missingScripts.map(s => (
148+
<button
149+
key={s.name}
150+
onClick={() => handleInstallScripts([s.name])}
151+
disabled={installingScripts}
152+
className="px-2 py-1.5 bg-port-border hover:bg-port-border/80 text-gray-300 rounded text-xs disabled:opacity-50"
153+
>
154+
Install {s.name}
155+
</button>
156+
))}
157+
</div>
158+
</div>
159+
</div>
160+
</div>
161+
)}
162+
93163
{/* Start Commands */}
94164
{app.startCommands?.length > 0 && (
95165
<div>

client/src/pages/Templates.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState, useEffect } from 'react';
22
import { useNavigate } from 'react-router-dom';
3-
import { Layers, Code, Server, Globe, Smartphone, Plus } from 'lucide-react';
3+
import { Layers, Code, Server, Globe, Smartphone, MonitorSmartphone, Plus } from 'lucide-react';
44
import * as api from '../services/api';
55
import DirectoryPicker from '../components/DirectoryPicker';
66

@@ -9,7 +9,8 @@ const ICONS = {
99
code: Code,
1010
server: Server,
1111
globe: Globe,
12-
smartphone: Smartphone
12+
smartphone: Smartphone,
13+
'monitor-smartphone': MonitorSmartphone
1314
};
1415

1516
export default function Templates() {

client/src/services/apiApps.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ export const getAppLogs = (id, lines = 100, processName) => {
6868
return request(`/apps/${id}/logs?${params}`);
6969
};
7070

71+
export const installXcodeScripts = (id, scripts) => request(`/apps/${id}/xcode-scripts/install`, {
72+
method: 'POST',
73+
body: JSON.stringify({ scripts })
74+
});
7175
export const getAppDocuments = (id) => request(`/apps/${id}/documents`);
7276
export const getAppDocument = (id, filename) => request(`/apps/${id}/documents/${filename}`);
7377
export const saveAppDocument = (id, filename, content, commitMessage) =>

server/routes/apps.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { safeJSONParse } from '../lib/fileUtils.js';
1818
import { parseEcosystemFromPath, usesPm2 } from '../services/streamingDetect.js';
1919
import { detectAppIcon, getIconContentType } from '../services/appIconDetect.js';
2020
import { hasDeployScript } from '../services/appDeployer.js';
21+
import { checkScripts, installScripts, XCODE_SCRIPT_NAMES } from '../services/xcodeScripts.js';
2122

2223
const router = Router();
2324

@@ -71,7 +72,7 @@ router.get('/', asyncHandler(async (req, res) => {
7172
const enriched = await Promise.all(apps.map(async (app) => {
7273
// Non-PM2 apps skip PM2 enrichment entirely
7374
if (!usesPm2(app.type)) {
74-
return { ...app, pm2Status: {}, overallStatus: 'n/a', hasDeployScript: hasDeployScript(app) };
75+
return { ...app, pm2Status: {}, overallStatus: 'n/a', hasDeployScript: hasDeployScript(app), xcodeScripts: checkScripts(app) };
7576
}
7677

7778
const pm2Home = app.pm2Home || null;
@@ -125,7 +126,8 @@ router.get('/', asyncHandler(async (req, res) => {
125126
apiPort,
126127
pm2Status: statuses,
127128
overallStatus,
128-
hasDeployScript: hasDeployScript(app)
129+
hasDeployScript: hasDeployScript(app),
130+
xcodeScripts: checkScripts(app)
129131
};
130132
}));
131133

@@ -183,7 +185,25 @@ router.get('/:id', loadApp, asyncHandler(async (req, res) => {
183185
appVersion = pkg?.version || null;
184186
}
185187

186-
res.json({ ...app, uiPort, devUiPort, apiPort, overallStatus, pm2Status: statuses, appVersion, hasDeployScript: hasDeployScript(app) });
188+
res.json({ ...app, uiPort, devUiPort, apiPort, overallStatus, pm2Status: statuses, appVersion, hasDeployScript: hasDeployScript(app), xcodeScripts: checkScripts(app) });
189+
}));
190+
191+
// POST /api/apps/:id/xcode-scripts/install - Install missing management scripts
192+
// Restrict the request payload to the known, fixed set of script names so that
193+
// arbitrary or oversized arrays are rejected at the validation layer.
194+
const installScriptsSchema = z.object({
195+
scripts: z.array(z.enum(XCODE_SCRIPT_NAMES)).min(1).max(XCODE_SCRIPT_NAMES.length)
196+
});
197+
router.post('/:id/xcode-scripts/install', loadApp, asyncHandler(async (req, res) => {
198+
const { scripts } = validateRequest(installScriptsSchema, req.body);
199+
if (!req.loadedApp.repoPath || !existsSync(req.loadedApp.repoPath)) {
200+
throw new ServerError('App repository path not found', { status: 400, code: 'PATH_NOT_FOUND' });
201+
}
202+
const result = await installScripts(req.loadedApp, scripts);
203+
if (result.errors.length && !result.installed.length) {
204+
throw new ServerError(result.errors.join(', '), { status: 400, code: 'INSTALL_FAILED' });
205+
}
206+
res.json(result);
187207
}));
188208

189209
// GET /api/apps/:id/icon - Serve the app's detected icon image

server/routes/apps.test.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,26 @@ vi.mock('../services/git.js', () => ({
5858
commit: vi.fn()
5959
}));
6060

61+
vi.mock('../services/xcodeScripts.js', () => ({
62+
checkScripts: vi.fn().mockReturnValue({ missing: [], present: [] }),
63+
installScripts: vi.fn(),
64+
XCODE_TEAM_ID: 'TEST_TEAM',
65+
XCODE_BUNDLE_PREFIX: 'net.test',
66+
XCODE_SCRIPT_NAMES: ['deploy.sh', 'take_screenshots.sh', 'take_screenshots_macos.sh'],
67+
toBundleId: vi.fn(),
68+
toTargetName: vi.fn(),
69+
generateDeployScript: vi.fn(),
70+
generateScreenshotScript: vi.fn(),
71+
generateMacScreenshotScript: vi.fn()
72+
}));
73+
6174
// Import mocked modules
6275
import * as appsService from '../services/apps.js';
6376
import * as pm2Service from '../services/pm2.js';
6477
import * as history from '../services/history.js';
6578
import * as streamingDetect from '../services/streamingDetect.js';
6679
import { detectAppIcon, getIconContentType } from '../services/appIconDetect.js';
80+
import { installScripts } from '../services/xcodeScripts.js';
6781
import { writeFileSync, mkdirSync, rmSync } from 'fs';
6882
import { join } from 'path';
6983
import { tmpdir } from 'os';
@@ -639,4 +653,89 @@ describe('Apps Routes', () => {
639653
expect(response.status).toBe(404);
640654
});
641655
});
656+
657+
describe('POST /api/apps/:id/xcode-scripts/install', () => {
658+
it('should install requested scripts successfully', async () => {
659+
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'Test App', type: 'xcode', repoPath: '/tmp' });
660+
installScripts.mockResolvedValue({ installed: ['deploy.sh'], skipped: [], errors: [] });
661+
662+
const response = await request(app)
663+
.post('/api/apps/app-001/xcode-scripts/install')
664+
.send({ scripts: ['deploy.sh'] });
665+
666+
expect(response.status).toBe(200);
667+
expect(response.body.installed).toEqual(['deploy.sh']);
668+
});
669+
670+
it('should return 400 when all scripts fail', async () => {
671+
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'Test App', type: 'xcode', repoPath: '/tmp' });
672+
installScripts.mockResolvedValue({ installed: [], skipped: [], errors: ['some failure'] });
673+
674+
const response = await request(app)
675+
.post('/api/apps/app-001/xcode-scripts/install')
676+
.send({ scripts: ['deploy.sh'] });
677+
678+
expect(response.status).toBe(400);
679+
expect(response.body.code).toBe('INSTALL_FAILED');
680+
});
681+
682+
it('should return 400 when scripts array contains an unknown name', async () => {
683+
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'Test App', type: 'xcode', repoPath: '/tmp' });
684+
685+
const response = await request(app)
686+
.post('/api/apps/app-001/xcode-scripts/install')
687+
.send({ scripts: ['bad.sh'] });
688+
689+
// Unknown script names are now rejected by the Zod enum validator
690+
expect(response.status).toBe(400);
691+
});
692+
693+
it('should return 400 when scripts array is empty', async () => {
694+
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'Test App', type: 'xcode', repoPath: '/tmp' });
695+
696+
const response = await request(app)
697+
.post('/api/apps/app-001/xcode-scripts/install')
698+
.send({ scripts: [] });
699+
700+
expect(response.status).toBe(400);
701+
});
702+
703+
it('should return 404 when app not found', async () => {
704+
appsService.getAppById.mockResolvedValue(null);
705+
706+
const response = await request(app)
707+
.post('/api/apps/app-999/xcode-scripts/install')
708+
.send({ scripts: ['deploy.sh'] });
709+
710+
expect(response.status).toBe(404);
711+
});
712+
713+
it('should return partial success with errors', async () => {
714+
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'Test App', type: 'xcode', repoPath: '/tmp' });
715+
installScripts.mockResolvedValue({
716+
installed: ['deploy.sh'],
717+
skipped: [],
718+
errors: ['Script take_screenshots_macos.sh does not apply to ios-native apps']
719+
});
720+
721+
const response = await request(app)
722+
.post('/api/apps/app-001/xcode-scripts/install')
723+
.send({ scripts: ['deploy.sh', 'take_screenshots_macos.sh'] });
724+
725+
expect(response.status).toBe(200);
726+
expect(response.body.installed).toEqual(['deploy.sh']);
727+
expect(response.body.errors).toHaveLength(1);
728+
});
729+
730+
it('should return 400 when repoPath does not exist', async () => {
731+
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'Test App', type: 'xcode', repoPath: '/nonexistent/path' });
732+
733+
const response = await request(app)
734+
.post('/api/apps/app-001/xcode-scripts/install')
735+
.send({ scripts: ['deploy.sh'] });
736+
737+
expect(response.status).toBe(400);
738+
expect(response.body.code).toBe('PATH_NOT_FOUND');
739+
});
740+
});
642741
});

0 commit comments

Comments
 (0)