From 79d4682e6576f45b883fb200f35ccaa5cdb0511d Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Fri, 2 Oct 2026 22:01:31 +0900 Subject: [PATCH] =?UTF-8?q?iOS=20=EC=8A=A4=ED=86=A0=EC=96=B4=20=EC=A0=9C?= =?UTF-8?q?=EC=B6=9C=20=EA=B5=AC=EC=84=B1=20=C2=B7=20Apple=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=C2=B7=20=ED=91=B8=EC=8B=9C=20=C2=B7=20Fir?= =?UTF-8?q?ebase=20=EC=84=A4=EC=A0=95=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.gitignore | 2 + app/BUILD.md | 28 +++++++- app/README.md | 2 +- app/ios/ExportOptions.plist | 40 +++++++++++ app/ios/Flutter/Debug.xcconfig | 4 ++ app/ios/Flutter/Release.xcconfig | 4 ++ app/ios/Runner.xcodeproj/project.pbxproj | 13 +++- app/ios/Runner/Info.plist | 24 +++++++ app/ios/Runner/Runner.entitlements | 21 ++++++ app/test/store_readiness_test.dart | 86 ++++++++++++++++++++++++ app/tool/ios/inject_url_scheme.py | 58 ++++++++++++++++ 11 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 app/ios/ExportOptions.plist create mode 100644 app/ios/Runner/Runner.entitlements create mode 100644 app/test/store_readiness_test.dart create mode 100755 app/tool/ios/inject_url_scheme.py 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())