549 lines
20 KiB
C#
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;
|
|
}
|
|
}
|