releases/src/Paca.App/Controls/DesktopAdBannerControl.xaml.cs

549 lines
20 KiB
C#

using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Microsoft.Web.WebView2.Core;
using Paca.Browser.Ads;
using Wv2 = Microsoft.Web.WebView2.Wpf;
namespace Paca.App.Controls;
/// <summary>
/// 데스크톱 브라우저 및 다운로드 뷰용 네이티브/실시간 웹 배너 컨트롤 (Designpaca 스위스 미니멀리즘)
/// </summary>
public partial class DesktopAdBannerControl : UserControl
{
private DesktopAdData? _adData;
private DesktopAdPlacement _placement;
private CoreWebView2Environment? _env;
private Wv2.WebView2? _webView;
private bool _isInitializing;
private readonly System.Threading.SemaphoreSlim _captureLock = new(1, 1);
public DesktopAdPlacement Placement
{
get => _placement;
set
{
_placement = value;
ApplyPlacementSizing();
}
}
public DesktopAdBannerControl() : this(DesktopAdPlacement.DownloadViewBanner)
{
}
private static readonly object s_initLock = new();
public DesktopAdBannerControl(DesktopAdPlacement placement)
{
_placement = placement;
lock (s_initLock)
{
InitializeComponent();
}
Loaded += OnLoaded;
}
private void ApplyPlacementSizing()
{
if (PART_WebViewContainer == null || PART_RootBorder == null) return;
PART_RootBorder.Padding = new Thickness(0);
PART_RootBorder.Background = System.Windows.Media.Brushes.Transparent;
PART_RootBorder.BorderThickness = new Thickness(0);
if (_placement == DesktopAdPlacement.BottomAppBanner)
{
PART_WebViewContainer.Width = 728;
PART_WebViewContainer.Height = 90;
PART_WebViewContainer.MinHeight = 90;
PART_RootBorder.Margin = new Thickness(0, 4, 0, 4);
Width = 728;
Height = 98;
}
else
{
PART_WebViewContainer.Width = 300;
PART_WebViewContainer.Height = 250;
PART_WebViewContainer.MinHeight = 250;
PART_RootBorder.Margin = new Thickness(0, 4, 0, 4);
Width = 300;
Height = 258;
}
}
private void LogAd(string msg)
{
try
{
var p = System.IO.Path.Combine(Paca.App.AppPaths.DataDir, "ad_debug.log");
System.IO.File.AppendAllText(p, $"[{DateTime.Now:HH:mm:ss.fff}] [{_placement}] {msg}\n");
}
catch { }
}
/// <summary>
/// 메인 윈도우의 공유 WebView2 환경을 주입하여 실제 구글 배너 광고 렌더링을 시작한다.
/// </summary>
public void AttachEnvironment(CoreWebView2Environment? env)
{
LogAd($"AttachEnvironment called. Env != null: {env != null}");
_env = env;
if (_adData == null)
{
_adData = DesktopAdManager.Instance.GetAdForPlacement(_placement);
}
TryLoadRealAdAsync();
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
LogAd("OnLoaded called");
RefreshAd();
}
public void RefreshAd()
{
_adData = DesktopAdManager.Instance.GetAdForPlacement(_placement);
if (_adData == null || !_adData.IsVisible)
{
Visibility = Visibility.Collapsed;
return;
}
Visibility = Visibility.Visible;
ApplyPlacementSizing();
PART_BadgeText.Text = _adData.Badge;
PART_TitleText.Text = _adData.Title;
PART_AdvertiserText.Text = _adData.Advertiser;
PART_DescText.Text = _adData.Description;
PART_CtaText.Text = _adData.CtaText;
PART_DismissBtn.Visibility = _adData.IsDismissible ? Visibility.Visible : Visibility.Collapsed;
if (!string.IsNullOrWhiteSpace(_adData.IconOrImageUrl))
{
try
{
var bitmap = new System.Windows.Media.Imaging.BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = new Uri(_adData.IconOrImageUrl, UriKind.Absolute);
bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
bitmap.EndInit();
PART_AdImage.Source = bitmap;
PART_AdImage.Visibility = Visibility.Visible;
PART_AdIconFallback.Visibility = Visibility.Collapsed;
}
catch
{
PART_AdImage.Visibility = Visibility.Collapsed;
PART_AdIconFallback.Visibility = Visibility.Visible;
}
}
else
{
PART_AdImage.Visibility = Visibility.Collapsed;
PART_AdIconFallback.Visibility = Visibility.Visible;
}
// 노출(Impression) 트래킹
DesktopAdManager.Instance.TrackImpression(_adData.Id);
if (_adData.IsRealAd)
{
TryLoadRealAdAsync();
}
}
private static Microsoft.Web.WebView2.Core.CoreWebView2Environment? _adEnvironment;
private static async System.Threading.Tasks.Task<Microsoft.Web.WebView2.Core.CoreWebView2Environment?> GetOrCreateAdEnvironmentAsync()
{
if (_adEnvironment != null) return _adEnvironment;
try
{
var adDataFolder = System.IO.Path.Combine(Paca.App.AppPaths.DataDir, "ad_profile");
System.IO.Directory.CreateDirectory(adDataFolder);
var options = new Microsoft.Web.WebView2.Core.CoreWebView2EnvironmentOptions
{
AreBrowserExtensionsEnabled = false,
AdditionalBrowserArguments = "--disable-extensions --allow-file-access-from-files"
};
_adEnvironment = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
browserExecutableFolder: null,
userDataFolder: adDataFolder,
options: options);
return _adEnvironment;
}
catch
{
return null;
}
}
private async void TryLoadRealAdAsync()
{
if (_isInitializing) return;
if (_adData == null)
{
_adData = DesktopAdManager.Instance.GetAdForPlacement(_placement);
}
if (_adData == null || !_adData.IsVisible) return;
_isInitializing = true;
try
{
if (_webView == null)
{
_webView = new Wv2.WebView2
{
DefaultBackgroundColor = System.Drawing.Color.FromArgb(30, 32, 40)
};
PART_WebViewContainer.Child = _webView;
}
if (_webView.CoreWebView2 == null)
{
var isolatedEnv = await GetOrCreateAdEnvironmentAsync().ConfigureAwait(true);
var env = isolatedEnv ?? _env ?? MainWindow.SharedEnvironment;
if (env != null)
{
LogAd($"EnsureCoreWebView2Async starting (isolated={isolatedEnv != null})...");
await _webView.EnsureCoreWebView2Async(env);
LogAd("EnsureCoreWebView2Async completed!");
}
else
{
LogAd("EnsureCoreWebView2Async aborted: env is null");
return;
}
}
_webView.NavigationCompleted -= OnNavigationCompleted;
_webView.NavigationCompleted += OnNavigationCompleted;
_webView.WebMessageReceived -= OnWebMessageReceived;
_webView.WebMessageReceived += OnWebMessageReceived;
if (_webView.CoreWebView2 != null)
{
_webView.CoreWebView2.WebMessageReceived -= OnWebMessageReceived;
_webView.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
}
var isBottom = _placement == DesktopAdPlacement.BottomAppBanner;
var adWidth = isBottom ? 728 : 300;
var adHeight = isBottom ? 90 : 250;
var divId = isBottom ? "div-gpt-ad-bottom" : "div-gpt-ad-banner";
if (_webView != null)
{
_webView.Width = adWidth;
_webView.Height = adHeight;
_webView.HorizontalAlignment = HorizontalAlignment.Center;
_webView.VerticalAlignment = VerticalAlignment.Center;
}
var slotId = !string.IsNullOrWhiteSpace(_adData.AdUnitId)
? _adData.AdUnitId
: (isBottom ? DesktopAdManager.Instance.Config.EffectiveNtpBannerUnitId : DesktopAdManager.Instance.Config.EffectiveDownloadBannerUnitId);
var snippet = _adData.HtmlEmbedSnippet ??
DesktopAdConfig.GenerateGoogleTagSnippet(slotId, adWidth, adHeight, divId);
var pageHtml = GenerateRealAdHtml(_adData, _placement, snippet, adWidth, adHeight);
var adDir = System.IO.Path.Combine(Paca.App.AppPaths.DataDir, "ad_html");
System.IO.Directory.CreateDirectory(adDir);
var tempAdHtmlPath = System.IO.Path.Combine(adDir, $"ad_{_placement}.html");
System.IO.File.WriteAllText(tempAdHtmlPath, pageHtml, new System.Text.UTF8Encoding(true));
// Google 크롤러 및 퍼블리셔 인증용 ads.txt 및 app-ads.txt를 가상 호스트 디렉터리에도 동기화
const string adsTxtLine = "google.com, pub-1039714767792854, DIRECT, f08c47fec0942fa0\n";
try
{
var localAdsTxt = System.IO.Path.Combine(adDir, "ads.txt");
var localAppAdsTxt = System.IO.Path.Combine(adDir, "app-ads.txt");
if (!System.IO.File.Exists(localAdsTxt)) System.IO.File.WriteAllText(localAdsTxt, adsTxtLine, new System.Text.UTF8Encoding(false));
if (!System.IO.File.Exists(localAppAdsTxt)) System.IO.File.WriteAllText(localAppAdsTxt, adsTxtLine, new System.Text.UTF8Encoding(false));
}
catch { }
ApplyPlacementSizing();
PART_RealAdHost.Visibility = Visibility.Visible;
PART_NativeCard.Visibility = Visibility.Collapsed;
var cacheBuster = DateTime.UtcNow.Ticks;
var virtualUrl = $"https://ads.paca.cloud/ad_{_placement}.html?v={cacheBuster}";
var fileUrl = new Uri(tempAdHtmlPath).AbsoluteUri + $"?v={cacheBuster}";
string targetUrl = virtualUrl;
try
{
_webView?.CoreWebView2?.SetVirtualHostNameToFolderMapping(
"ads.paca.cloud",
adDir,
Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind.Allow);
}
catch (Exception ex)
{
LogAd($"SetVirtualHostNameToFolderMapping warning: {ex.Message}, fallback to fileUrl");
targetUrl = fileUrl;
}
LogAd($"Loading real Google ad: {targetUrl} (file fallback: {fileUrl})");
try
{
_webView?.CoreWebView2?.Navigate(targetUrl);
}
catch (Exception ex)
{
LogAd($"Navigate target failed ({ex.Message}), trying fileUrl fallback...");
try
{
_webView?.CoreWebView2?.Navigate(fileUrl);
}
catch
{
_webView?.CoreWebView2?.NavigateToString(pageHtml);
}
}
}
catch (Exception ex)
{
LogAd($"TryLoadRealAdAsync EXCEPTION: {ex}");
PART_RealAdHost.Visibility = Visibility.Collapsed;
PART_NativeCard.Visibility = Visibility.Visible;
}
finally
{
_isInitializing = false;
}
}
private static string GenerateRealAdHtml(DesktopAdData ad, DesktopAdPlacement placement, string snippet, int width, int height)
{
return $@"<!DOCTYPE html>
<html lang=""ko"">
<head>
<meta charset=""utf-8"">
<meta name=""viewport"" content=""width={width}, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"">
<meta name=""referrer"" content=""always"">
<meta name=""google-adsense-account"" content=""ca-pub-1039714767792854"">
<link rel=""canonical"" href=""https://ads.paca.cloud/ad_{placement}.html"">
<title>PACA Google Ad</title>
<script>
(function() {{
var send = function(level, args) {{
if (window.chrome && window.chrome.webview) {{
try {{
var str = Array.from(args).map(function(a) {{ return typeof a === 'object' ? JSON.stringify(a) : String(a); }}).join(' ');
window.chrome.webview.postMessage(JSON.stringify({{ type: 'JS_CONSOLE', level: level, msg: str }}));
}} catch(_) {{}}
}}
}};
var oldLog = console.log, oldWarn = console.warn, oldErr = console.error;
console.log = function() {{ send('LOG', arguments); oldLog.apply(console, arguments); }};
console.warn = function() {{ send('WARN', arguments); oldWarn.apply(console, arguments); }};
console.error = function() {{ send('ERROR', arguments); oldErr.apply(console, arguments); }};
window.onerror = function(msg, url, line) {{ send('ERROR', [msg, url, line]); }};
}})();
</script>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; user-select: none; }}
html, body {{
background: #181A22;
color: #F8FAFC;
width: {width}px;
height: {height}px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
}}
.ad-google-wrapper {{
width: {width}px;
height: {height}px;
min-width: 320px;
margin-left: auto;
margin-right: auto;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}}
</style>
</head>
<body>
<div class=""ad-google-wrapper"">
{snippet}
</div>
<script>
window.addEventListener('message', function(e) {{
if (window.chrome && window.chrome.webview) {{
try {{
var data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
window.chrome.webview.postMessage(JSON.stringify(data));
}} catch(_) {{}}
}}
}});
</script>
</body>
</html>";
}
private async void OnNavigationCompleted(object? sender, Microsoft.Web.WebView2.Core.CoreWebView2NavigationCompletedEventArgs e)
{
LogAd($"OnNavigationCompleted: IsSuccess={e.IsSuccess}, WebErrorStatus={e.WebErrorStatus}, HttpStatusCode={e.HttpStatusCode}");
if (e.IsSuccess)
{
try
{
var previewsDir = System.IO.Path.Combine(Paca.App.AppPaths.DataDir, "ad_previews");
var previewFile = System.IO.Path.Combine(previewsDir, $"ad_{_placement}.png");
// 1차: 기본 DOM 준비 대기
await System.Threading.Tasks.Task.Delay(1000);
await CaptureAdPreviewAsync(previewFile);
// 2차: Google 원격 실시간 광고(gpt.js/doubleclick) 네트워크 다운로드 및 iframe 렌더링 완료 대기 후 정밀 캡처
await System.Threading.Tasks.Task.Delay(3000);
await CaptureAdPreviewAsync(previewFile);
}
catch { }
}
}
/// <summary>
/// WebView2의 실제 렌더링 화면 비트맵을 직접 캡처하여 PNG 파일로 저장한다.
/// </summary>
public async System.Threading.Tasks.Task<bool> CaptureAdPreviewAsync(string outputPath)
{
if (_webView?.CoreWebView2 == null) return false;
await _captureLock.WaitAsync();
try
{
var dir = System.IO.Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(dir)) System.IO.Directory.CreateDirectory(dir);
using var stream = System.IO.File.Create(outputPath);
await _webView.CoreWebView2.CapturePreviewAsync(Microsoft.Web.WebView2.Core.CoreWebView2CapturePreviewImageFormat.Png, stream);
LogAd($"CaptureAdPreviewAsync saved: {outputPath}");
return System.IO.File.Exists(outputPath) && new System.IO.FileInfo(outputPath).Length > 0;
}
catch (Exception ex)
{
LogAd($"CaptureAdPreviewAsync EXCEPTION: {ex}");
return false;
}
finally
{
_captureLock.Release();
}
}
private void OnWebMessageReceived(object? sender, Microsoft.Web.WebView2.Core.CoreWebView2WebMessageReceivedEventArgs e)
{
try
{
var msg = e.TryGetWebMessageAsString();
if (string.IsNullOrWhiteSpace(msg)) return;
LogAd($"OnWebMessageReceived: {msg}");
using var doc = System.Text.Json.JsonDocument.Parse(msg);
if (doc.RootElement.TryGetProperty("type", out var typeProp))
{
var type = typeProp.GetString();
if (type == "JS_CONSOLE")
{
var level = doc.RootElement.TryGetProperty("level", out var l) ? l.GetString() : "LOG";
var text = doc.RootElement.TryGetProperty("msg", out var m) ? m.GetString() : "";
LogAd($"[Browser {level}] {text}");
return;
}
if (type == "AD_RENDERED")
{
LogAd($"AD_RENDERED event received from Google ad slot!");
_ = System.Threading.Tasks.Task.Run(async () =>
{
await System.Threading.Tasks.Task.Delay(500);
var previewsDir = System.IO.Path.Combine(Paca.App.AppPaths.DataDir, "ad_previews");
var previewFile = System.IO.Path.Combine(previewsDir, $"ad_{_placement}.png");
await Dispatcher.InvokeAsync(() => CaptureAdPreviewAsync(previewFile));
});
return;
}
if (type == "AD_CLICK")
{
Dispatcher.Invoke(() => HandleAction());
return;
}
if (type == "AD_DISMISS")
{
Dispatcher.Invoke(() =>
{
if (_adData != null) DesktopAdManager.Instance.DismissAd(_adData.Id);
Visibility = Visibility.Collapsed;
});
return;
}
}
Dispatcher.Invoke(() =>
{
PART_RealAdHost.Visibility = Visibility.Visible;
PART_NativeCard.Visibility = Visibility.Collapsed;
});
}
catch (Exception ex)
{
LogAd($"OnWebMessageReceived EXCEPTION: {ex}");
}
}
public void OnCtaClicked(object sender, RoutedEventArgs e)
{
HandleAction();
}
public void OnCardClicked(object sender, MouseButtonEventArgs e)
{
HandleAction();
}
private void HandleAction()
{
if (_adData == null) return;
DesktopAdManager.Instance.TrackClick(_adData.Id);
if (!string.IsNullOrWhiteSpace(_adData.TargetUrl))
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = _adData.TargetUrl,
UseShellExecute = true
});
}
catch
{
// 실패 방어
}
}
}
public void OnDismissClicked(object sender, RoutedEventArgs e)
{
if (_adData == null) return;
DesktopAdManager.Instance.DismissAd(_adData.Id);
Visibility = Visibility.Collapsed;
}
}