diff --git a/app/.gitignore b/app/.gitignore
index d7873b9..578cdc8 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -55,6 +55,8 @@ key.properties
*.mobileprovision
**/GoogleService-Info.plist
**/google-services.json
+# GoogleService-Info.plist 에서 생성한다 — tool/ios/inject_url_scheme.py
+ios/config/Google.xcconfig
# FVM Version Cache
.fvm/
diff --git a/app/BUILD.md b/app/BUILD.md
index f9c530a..761cf8e 100644
--- a/app/BUILD.md
+++ b/app/BUILD.md
@@ -100,8 +100,32 @@ export DEVELOPER_DIR=~/Downloads/Xcode-beta.app/Contents/Developer
fvm flutter build ios --release --no-codesign
```
-**서명은 Apple Developer 계정이 있어야 한다.** 계정 없이 여기까지는 확인되므로,
-TestFlight 제출에 남은 것은 **서명과 업로드뿐**이다.
+### 제출 빌드 — App Store Connect
+
+서명 팀은 **Fancake Inc. `V28G543VK5`** 다(`Runner.xcodeproj` 의 `DEVELOPMENT_TEAM`,
+`ios/ExportOptions.plist` 의 `teamID`). 번들 ID `kr.co.fanuzu.app`.
+
+| 항목 | 파일 | 없으면 |
+|---|---|---|
+| Sign in with Apple · 푸시 환경 | `ios/Runner/Runner.entitlements` | Apple 로그인이 동작하지 않고 심사에서 반려된다 |
+| 암호화 사용 신고 | `Info.plist` `ITSAppUsesNonExemptEncryption` | 업로드마다 수동 응답을 요구한다 |
+| Google 콜백 스킴 | `Info.plist` + `ios/config/Google.xcconfig` | Google 로그인 콜백이 돌아오지 않는다 |
+| Firebase 설정 | `ios/Runner/GoogleService-Info.plist` (번들 리소스) | 목 모드로 뜨고 Google 로그인 client ID 를 못 읽는다 |
+| 내보내기 설정 | `ios/ExportOptions.plist` | `-exportArchive` 가 대화형으로 묻는다 |
+
+`GoogleService-Info.plist` 와 `ios/config/Google.xcconfig` 는 저장소에 넣지 않는다.
+Xcode 프로젝트가 `GoogleService-Info.plist` 를 리소스로 참조하므로 **파일이 없으면 iOS 빌드가 멈춘다.**
+
+```bash
+# 1. Firebase 콘솔에서 받은 iOS 설정 파일을 둔다
+cp <받은 파일> ios/Runner/GoogleService-Info.plist
+# 2. Google 콜백 스킴을 뽑는다 → ios/config/Google.xcconfig
+python3 tool/ios/inject_url_scheme.py
+# 3. 아카이브 · IPA
+fvm flutter build ipa --release --export-options-plist=ios/ExportOptions.plist
+```
+
+배포 프로파일이 없으면 `xcodebuild` 로 나눠 `-allowProvisioningUpdates` 를 붙인다.
### 정리해야 할 것 — CocoaPods / SwiftPM 이중 상태
diff --git a/app/README.md b/app/README.md
index 7e736a8..3cbbfd7 100644
--- a/app/README.md
+++ b/app/README.md
@@ -103,7 +103,7 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
|---|---|---|---|
| S1 | Firebase 프로젝트(dev/prod) 개설 | — | 아래 S2 를 만들 수 없다 |
| S2 | `google-services.json` | `app/android/app/google-services.json` | 목 모드로 기동. Google 로그인 차단 |
-| S2 | `GoogleService-Info.plist` | `app/ios/Runner/GoogleService-Info.plist` | 목 모드로 기동. Google·Apple 로그인 차단 |
+| S2 | `GoogleService-Info.plist` | `app/ios/Runner/GoogleService-Info.plist` | iOS 빌드가 멈춘다(번들 리소스로 참조). 투입 절차는 `BUILD.md` 「제출 빌드」 |
> S2 투입 시 Android 는 `android/settings.gradle.kts` 에 `com.google.gms.google-services` 플러그인
> 추가가 함께 필요하다. 지금은 설정 파일이 없어 플러그인을 적용하지 않았다 —
diff --git a/app/ios/ExportOptions.plist b/app/ios/ExportOptions.plist
new file mode 100644
index 0000000..6f7de16
--- /dev/null
+++ b/app/ios/ExportOptions.plist
@@ -0,0 +1,40 @@
+
+
+
+
+
+ method
+ app-store-connect
+
+
+ teamID
+ V28G543VK5
+
+
+ signingStyle
+ automatic
+
+
+ uploadSymbols
+
+
+
+ uploadBitcode
+
+
+
+ thinning
+ <thin-for-all-variants>
+
+
diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig
index ec97fc6..093bdc1 100644
--- a/app/ios/Flutter/Debug.xcconfig
+++ b/app/ios/Flutter/Debug.xcconfig
@@ -1,2 +1,6 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+
+// Google 로그인 콜백 스킴. tool/ios/inject_url_scheme.py 가 만든다.
+// `#include?` 는 파일이 없어도 빌드를 멈추지 않는다 — 그때는 Google 로그인만 동작하지 않는다.
+#include? "../config/Google.xcconfig"
diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig
index c4855bf..02fef1a 100644
--- a/app/ios/Flutter/Release.xcconfig
+++ b/app/ios/Flutter/Release.xcconfig
@@ -1,2 +1,6 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+
+// Google 로그인 콜백 스킴. tool/ios/inject_url_scheme.py 가 만든다.
+// `#include?` 는 파일이 없어도 빌드를 멈추지 않는다 — 그때는 Google 로그인만 동작하지 않는다.
+#include? "../config/Google.xcconfig"
diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj
index 9b6d8b5..a848bd9 100644
--- a/app/ios/Runner.xcodeproj/project.pbxproj
+++ b/app/ios/Runner.xcodeproj/project.pbxproj
@@ -15,6 +15,7 @@
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
87BFB00ABC23EA249E841B59 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CBE157220A7C382F845775F0 /* Pods_Runner.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 4F2B0E1A2C3D4E5F60718294 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
EC46D318C684524E6F9C925F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */; };
@@ -64,6 +65,7 @@
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
A68C3B80D45981965B89CA40 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; };
AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -149,6 +151,7 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
+ 4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
@@ -275,6 +278,7 @@
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 4F2B0E1A2C3D4E5F60718294 /* GoogleService-Info.plist in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -467,7 +471,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
- DEVELOPMENT_TEAM = P36BV95LP5;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
+ DEVELOPMENT_TEAM = V28G543VK5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -650,7 +655,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
- DEVELOPMENT_TEAM = P36BV95LP5;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
+ DEVELOPMENT_TEAM = V28G543VK5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -673,7 +679,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
- DEVELOPMENT_TEAM = P36BV95LP5;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
+ DEVELOPMENT_TEAM = V28G543VK5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist
index 00504d2..8798140 100644
--- a/app/ios/Runner/Info.plist
+++ b/app/ios/Runner/Info.plist
@@ -66,5 +66,29 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
+
+ ITSAppUsesNonExemptEncryption
+
+
+
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ google-sign-in
+ CFBundleURLSchemes
+
+ $(GOOGLE_REVERSED_CLIENT_ID)
+
+
+
diff --git a/app/ios/Runner/Runner.entitlements b/app/ios/Runner/Runner.entitlements
new file mode 100644
index 0000000..53eb7ec
--- /dev/null
+++ b/app/ios/Runner/Runner.entitlements
@@ -0,0 +1,21 @@
+
+
+
+
+
+ com.apple.developer.applesignin
+
+ Default
+
+
+
+ aps-environment
+ development
+
+
diff --git a/app/test/store_readiness_test.dart b/app/test/store_readiness_test.dart
new file mode 100644
index 0000000..bd59af9
--- /dev/null
+++ b/app/test/store_readiness_test.dart
@@ -0,0 +1,86 @@
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+
+/// iOS 스토어 제출 구성 검사.
+///
+/// 여기 있는 것들은 사라져도 앱이 멀쩡히 빌드된다. 문제는 업로드·심사·로그인
+/// 시점에야 드러나고, 그때는 원인이 코드처럼 보인다. 파일을 문자열로 읽어
+/// 키가 있는지만 본다 — 값의 해석은 빌드가 한다.
+
+const _teamId = 'V28G543VK5'; // Fancake Inc.
+
+String _read(String path) {
+ final file = File(path);
+ expect(file.existsSync(), isTrue, reason: '$path 가 없습니다');
+ return file.readAsStringSync();
+}
+
+void main() {
+ group('iOS — 서명', () {
+ test('Xcode 프로젝트와 내보내기 설정이 발주사 팀으로 서명한다', () {
+ final pbx = _read('ios/Runner.xcodeproj/project.pbxproj');
+ final teams = RegExp(r'DEVELOPMENT_TEAM = (\w+);')
+ .allMatches(pbx)
+ .map((m) => m.group(1))
+ .toSet();
+ expect(teams, {_teamId});
+ expect(_read('ios/ExportOptions.plist'), contains(_teamId));
+ });
+
+ test('Sign in with Apple Capability 가 서명에 연결되어 있다', () {
+ expect(
+ _read('ios/Runner/Runner.entitlements'),
+ contains('com.apple.developer.applesignin'),
+ );
+ expect(
+ 'CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;'
+ .allMatches(_read('ios/Runner.xcodeproj/project.pbxproj'))
+ .length,
+ 3, // Debug · Release · Profile
+ );
+ });
+ });
+
+ group('iOS — 제출 선언과 Firebase 설정', () {
+ late final String plist = _read('ios/Runner/Info.plist');
+
+ test('암호화 사용 신고가 있어 업로드가 멈추지 않는다', () {
+ expect(plist, contains('ITSAppUsesNonExemptEncryption'));
+ });
+
+ test('Google 로그인 콜백 스킴이 설정 파일 값으로 채워진다', () {
+ expect(plist, contains(r'$(GOOGLE_REVERSED_CLIENT_ID)'));
+ for (final c in const ['Debug', 'Release']) {
+ expect(
+ _read('ios/Flutter/$c.xcconfig'),
+ contains('#include? "../config/Google.xcconfig"'),
+ reason: c,
+ );
+ }
+ });
+
+ test('GoogleService-Info.plist 가 앱 번들 리소스로 들어간다', () {
+ // 번들에 없으면 Firebase 가 목 모드로 뜨고 Google 로그인 client ID 도 못 읽는다.
+ expect(
+ _read('ios/Runner.xcodeproj/project.pbxproj'),
+ contains('/* GoogleService-Info.plist in Resources */,'),
+ );
+ });
+ });
+
+ test('설정·서명 자산을 git 이 추적하지 않는다', () {
+ final result = Process.runSync('git', [
+ 'ls-files',
+ '--',
+ 'ios/Runner/GoogleService-Info.plist',
+ 'ios/config/Google.xcconfig',
+ 'android/app/google-services.json',
+ '*.p12',
+ '*.p8',
+ '*.mobileprovision',
+ ]);
+ expect(result.exitCode, 0, reason: 'git 을 실행하지 못했습니다');
+ expect((result.stdout as String).trim(), isEmpty);
+ });
+}
diff --git a/app/tool/ios/inject_url_scheme.py b/app/tool/ios/inject_url_scheme.py
new file mode 100755
index 0000000..5d56d67
--- /dev/null
+++ b/app/tool/ios/inject_url_scheme.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+"""GoogleService-Info.plist 의 REVERSED_CLIENT_ID 를 Xcode 빌드 설정에 넣는다.
+
+**왜 스크립트인가.** Google 로그인 콜백 스킴은 Firebase 프로젝트마다 다르고,
+Info.plist 에 손으로 옮기면 오타 한 글자에 로그인이 **조용히** 실패한다.
+오류도 뜨지 않고 콜백만 돌아오지 않아 원인을 찾는 데 한나절이 든다.
+
+설정 파일이 투입될 때 한 번 돌리면 `ios/config/Google.xcconfig` 가 만들어지고,
+Info.plist 의 `$(GOOGLE_REVERSED_CLIENT_ID)` 가 그 값으로 치환된다.
+
+실행: python3 tool/ios/inject_url_scheme.py
+"""
+from __future__ import annotations
+
+import plistlib
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+SOURCE = ROOT / "ios/Runner/GoogleService-Info.plist"
+OUT = ROOT / "ios/config/Google.xcconfig"
+
+
+def main() -> int:
+ if not SOURCE.exists():
+ print(
+ f"설정 파일이 아직 없습니다: {SOURCE.relative_to(ROOT)}\n"
+ "Firebase 콘솔에서 iOS 앱 설정 파일을 받아 그 자리에 두십시오.",
+ file=sys.stderr,
+ )
+ return 1
+
+ with SOURCE.open("rb") as f:
+ data = plistlib.load(f)
+
+ reversed_id = data.get("REVERSED_CLIENT_ID")
+ if not reversed_id:
+ # Firebase 콘솔에서 Google 로그인을 켜지 않으면 이 키가 빠진 채로 나온다.
+ print(
+ "REVERSED_CLIENT_ID 가 없습니다. Firebase 콘솔에서 "
+ "Authentication → Google 제공자를 켠 뒤 설정 파일을 다시 받으십시오.",
+ file=sys.stderr,
+ )
+ return 1
+
+ OUT.parent.mkdir(parents=True, exist_ok=True)
+ OUT.write_text(
+ "// 자동 생성 — tool/ios/inject_url_scheme.py\n"
+ "// GoogleService-Info.plist 가 바뀌면 다시 돌리십시오.\n"
+ f"GOOGLE_REVERSED_CLIENT_ID = {reversed_id}\n",
+ encoding="utf-8",
+ )
+ print(f"기록: {OUT.relative_to(ROOT)}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())