Compare commits

..

No commits in common. "main" and "feat-v1" have entirely different histories.

133 changed files with 125 additions and 14480 deletions

4
.gitignore vendored
View File

@ -361,6 +361,4 @@ MigrationBackup/
.ionide/ .ionide/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
**/.DS_Store

View File

@ -1,25 +0,0 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace VideoConcat.Conversions
{
public class StatusToEnabledConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is string status)
{
// 只有状态为"拼接成功"时才启用预览按钮
return status == "拼接成功";
}
return false;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@ -19,109 +19,18 @@ using static VideoConcat.Models.VideoModel;
namespace VideoConcat.Models namespace VideoConcat.Models
{ {
/// <summary>
/// 抽帧处理方式
/// </summary>
public enum ExtractFrameMode
{
DeleteFrame = 0, // 删除帧
AddTransparentImage = 1 // 添加透明图
}
/// <summary>
/// 处理任务信息(用于表格显示)
/// </summary>
public class ExtractTaskItem : INotifyPropertyChanged
{
private string _index = "";
private string _fileName = "";
private string _fullFileName = "";
private string _status = "";
private string _originalSize = "";
private string _outputSize = "";
private string _sizeChange = "";
private string _duration = "";
private string _progress = "";
public string Index
{
get => _index;
set { _index = value; OnPropertyChanged(); }
}
public string FileName
{
get => _fileName;
set { _fileName = value; OnPropertyChanged(); }
}
/// <summary>
/// 完整文件名用于ToolTip显示
/// </summary>
public string FullFileName
{
get => _fullFileName;
set { _fullFileName = value; OnPropertyChanged(); }
}
public string Status
{
get => _status;
set { _status = value; OnPropertyChanged(); }
}
public string OriginalSize
{
get => _originalSize;
set { _originalSize = value; OnPropertyChanged(); }
}
public string OutputSize
{
get => _outputSize;
set { _outputSize = value; OnPropertyChanged(); }
}
public string SizeChange
{
get => _sizeChange;
set { _sizeChange = value; OnPropertyChanged(); }
}
public string Duration
{
get => _duration;
set { _duration = value; OnPropertyChanged(); }
}
public string Progress
{
get => _progress;
set { _progress = value; OnPropertyChanged(); }
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
public class ExtractWindowModel : INotifyPropertyChanged public class ExtractWindowModel : INotifyPropertyChanged
{ {
private string _folderPath = ""; private string _folderPath = "";
private string _helpInfo = ""; private string _helpInfo = "";
private bool _canExtractFrame = false; private bool _canStart = false;
private bool _canModify = false;
private bool _isCanOperate = false; private bool _isCanOperate = false;
private bool _isStart = false; private bool _isStart = false;
private string[] _videos = []; private string[] _videos = [];
private int _extractCount = 1;
private Dispatcher _dispatcher; private Dispatcher _dispatcher;
private ExtractFrameMode _extractFrameMode = ExtractFrameMode.DeleteFrame;
private ObservableCollection<ExtractTaskItem> _taskItems = new();
public string[] videos public string[] videos
{ {
@ -193,28 +102,18 @@ namespace VideoConcat.Models
} }
} }
public bool CanExtractFrame public bool CanStart
{ {
get { return _canExtractFrame; } get { return _canStart; }
set set
{ {
_canExtractFrame = value; _canStart = value;
OnPropertyChanged(nameof(CanExtractFrame)); OnPropertyChanged(nameof(CanStart));
}
}
public bool CanModify
{
get { return _canModify; }
set
{
_canModify = value;
OnPropertyChanged(nameof(CanModify));
} }
} }
public ICommand? BtnOpenFolderCommand { get; set; } public ICommand? BtnOpenFolderCommand { get; set; }
public ICommand? BtnStartVideoConcatCommand { get; set; } public ICommand? BtnStartVideoConcatCommand { get; set; }
public ICommand? BtnStartVideoModifyCommand { get; set; }
public ICommand? BtnChooseAuditImageCommand { get; set; } public ICommand? BtnChooseAuditImageCommand { get; set; }
public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangedEventHandler? PropertyChanged;
@ -231,96 +130,15 @@ namespace VideoConcat.Models
_dispatcher = Dispatcher.CurrentDispatcher; _dispatcher = Dispatcher.CurrentDispatcher;
} }
public int ExtractCount
{
get => _extractCount;
set
{
_extractCount = value;
OnPropertyChanged();
SetCanStart();
}
}
public ExtractFrameMode ExtractFrameMode
{
get => _extractFrameMode;
set
{
_extractFrameMode = value;
OnPropertyChanged();
OnPropertyChanged(nameof(IsDeleteFrameMode));
OnPropertyChanged(nameof(IsAddTransparentImageMode));
}
}
/// <summary>
/// 是否选择删除帧模式用于RadioButton绑定
/// </summary>
public bool IsDeleteFrameMode
{
get => _extractFrameMode == ExtractFrameMode.DeleteFrame;
set
{
if (value && _extractFrameMode != ExtractFrameMode.DeleteFrame)
{
ExtractFrameMode = ExtractFrameMode.DeleteFrame;
}
}
}
/// <summary>
/// 是否选择添加透明图模式用于RadioButton绑定
/// </summary>
public bool IsAddTransparentImageMode
{
get => _extractFrameMode == ExtractFrameMode.AddTransparentImage;
set
{
if (value && _extractFrameMode != ExtractFrameMode.AddTransparentImage)
{
ExtractFrameMode = ExtractFrameMode.AddTransparentImage;
}
}
}
/// <summary>
/// 抽帧按钮的文本(固定显示"操作"
/// </summary>
public string ExtractButtonText
{
get
{
return "操作";
}
}
/// <summary>
/// 任务列表(用于表格显示)
/// </summary>
public ObservableCollection<ExtractTaskItem> TaskItems
{
get => _taskItems;
set
{
_taskItems = value;
OnPropertyChanged();
}
}
public void SetCanStart() public void SetCanStart()
{ {
// 有视频文件且生成个数大于0时可以抽帧
CanExtractFrame = videos.Length > 0 && _extractCount > 0;
// 有视频文件时可以修改
if (videos.Length > 0) if (videos.Length > 0)
{ {
CanModify = true; CanStart = true;
} }
else else
{ {
CanModify = false; CanStart = false;
} }
} }
} }

View File

@ -110,153 +110,14 @@ namespace VideoConcat.Models
} }
public class ConcatVideo : INotifyPropertyChanged public class ConcatVideo
{ {
private int _index; public int Index { set; get; }
private string _fileName = ""; public string FileName { set; get; } = "";
private string _filePath = ""; public string Size { set; get; } = "";
private string _size = ""; public int Seconds { set; get; }
private int _seconds; public string Status { set; get; } = "";
private string _status = ""; public string Progress { set; get; } = "";
private string _progress = "";
public int Index
{
get => _index;
set
{
_index = value;
OnPropertyChanged();
}
}
public string FileName
{
get => _fileName;
set
{
_fileName = value;
OnPropertyChanged();
}
}
public string FilePath
{
get => _filePath;
set
{
_filePath = value;
OnPropertyChanged();
}
}
public string Size
{
get => _size;
set
{
_size = value;
OnPropertyChanged();
}
}
public int Seconds
{
get => _seconds;
set
{
_seconds = value;
OnPropertyChanged();
}
}
public string Status
{
get => _status;
set
{
_status = value;
OnPropertyChanged();
}
}
public string Progress
{
get => _progress;
set
{
_progress = value;
OnPropertyChanged();
// 同时更新进度条数值
UpdateProgressValue();
}
}
private double _progressValue = 0;
public double ProgressValue
{
get => _progressValue;
set
{
_progressValue = value;
OnPropertyChanged();
}
}
private void UpdateProgressValue()
{
// 从进度文本中提取百分比数值
if (string.IsNullOrEmpty(_progress))
{
ProgressValue = 0;
return;
}
// 处理各种进度格式: "50%", "50", "拼接中...", "100%", "失败" 等
if (_progress.EndsWith("%"))
{
string percentStr = _progress.Replace("%", "").Trim();
if (double.TryParse(percentStr, out double percent))
{
ProgressValue = percent;
}
else
{
ProgressValue = 0;
}
}
else if (_progress == "拼接成功" || _progress == "100%")
{
ProgressValue = 100;
}
else if (_progress == "拼接失败" || _progress == "失败")
{
ProgressValue = 0;
}
else if (_progress.Contains("中") || _progress.Contains("..."))
{
// 处理中状态,保持当前进度或设置为中间值
if (ProgressValue < 10)
{
ProgressValue = 10; // 开始处理时显示10%
}
}
else
{
// 尝试解析为数字
if (double.TryParse(_progress, out double value))
{
ProgressValue = value;
}
}
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
} }
public ObservableCollection<FolderInfo> FolderInfos public ObservableCollection<FolderInfo> FolderInfos

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,15 @@
using System.Windows.Input;
using VideoConcat.Models;
using MessageBox = System.Windows.MessageBox;
using VideoConcat.Common.Tools;
using System.IO;
using Microsoft.Expression.Drawing.Core;
using FFMpegCore; using FFMpegCore;
using FFMpegCore.Enums; using FFMpegCore.Enums;
using Microsoft.Expression.Drawing.Core;
using System.IO;
using System.Threading;
using System.Windows;
using System.Windows.Forms;
using System.Windows.Input;
using System.Windows.Threading;
using VideoConcat.Common.Tools;
using VideoConcat.Models;
using VideoConcat.Services.Video;
using static VideoConcat.Models.VideoModel; using static VideoConcat.Models.VideoModel;
using MessageBox = System.Windows.MessageBox; using System.Windows.Threading;
using System.Windows;
using VideoConcat.Services.Video;
namespace VideoConcat.ViewModels namespace VideoConcat.ViewModels
{ {
@ -36,8 +34,7 @@ namespace VideoConcat.ViewModels
{ {
ExtractWindowModel = new ExtractWindowModel ExtractWindowModel = new ExtractWindowModel
{ {
CanExtractFrame = false, CanStart = false,
CanModify = false,
IsStart = false, IsStart = false,
IsCanOperate = true, IsCanOperate = true,
@ -45,8 +42,8 @@ namespace VideoConcat.ViewModels
{ {
DoExcue = obj => DoExcue = obj =>
{ {
System.Windows.Forms.FolderBrowserDialog folderBrowserDialog = new(); FolderBrowserDialog folderBrowserDialog = new();
if (folderBrowserDialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) if (folderBrowserDialog.ShowDialog() == DialogResult.OK)
{ {
ExtractWindowModel.FolderPath = folderBrowserDialog.SelectedPath; ExtractWindowModel.FolderPath = folderBrowserDialog.SelectedPath;
LogUtils.Info($"获取视频文件夹,视频路径:{ExtractWindowModel.FolderPath}"); LogUtils.Info($"获取视频文件夹,视频路径:{ExtractWindowModel.FolderPath}");
@ -57,452 +54,24 @@ namespace VideoConcat.ViewModels
}, },
BtnStartVideoConcatCommand = new Command() BtnStartVideoConcatCommand = new Command()
{
DoExcue = obj =>
{
// 在后台任务中执行异步操作
Task.Run(async () =>
{
int extractCount = ExtractWindowModel.ExtractCount;
if (extractCount <= 0)
{
ExtractWindowModel.Dispatcher.Invoke(() =>
{
MessageBox.Show("请输入有效的生成个数大于0", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
});
return;
}
string modeText = ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame ? "方案1" : "方案2";
DateTime startTime = DateTime.Now;
ExtractWindowModel.Dispatcher.Invoke(() =>
{
ExtractWindowModel.HelpInfo = $"处理方案:{modeText} | 视频数量:{ExtractWindowModel.videos.Length} 个 | 每个生成:{extractCount} 个 | 总任务数:{ExtractWindowModel.videos.Length * extractCount} 个 | 开始时间:{startTime:yyyy-MM-dd HH:mm:ss}";
// 清空任务列表,以便重新开始
ExtractWindowModel.TaskItems.Clear();
ExtractWindowModel.IsStart = true;
ExtractWindowModel.IsCanOperate = false;
});
SemaphoreSlim semaphore = new(10); // 限制并发数量
List<Task> _tasks = [];
int totalTasks = ExtractWindowModel.videos.Length * extractCount;
int completedTasks = 0;
System.Collections.Concurrent.ConcurrentBag<string> errorMessages = new(); // 收集错误信息
System.Collections.Concurrent.ConcurrentBag<string> successMessages = new(); // 收集成功信息
long totalOriginalSize = 0; // 原始文件总大小
long totalOutputSize = 0; // 输出文件总大小
int successCount = 0; // 成功数量
int skipCount = 0; // 跳过数量(已存在)
// 对每个视频生成指定数量的抽帧视频
foreach (var video in ExtractWindowModel.videos)
{
for (int i = 1; i <= extractCount; i++)
{
int currentIndex = i; // 闭包变量
string currentVideo = video; // 闭包变量
await semaphore.WaitAsync();
var _task = Task.Run(async () =>
{
try
{
string _tmpPath = Path.GetDirectoryName(currentVideo) ?? "";
if (string.IsNullOrEmpty(_tmpPath))
{
LogUtils.Error($"无法获取视频目录:{currentVideo}");
Interlocked.Increment(ref completedTasks);
ExtractWindowModel.Dispatcher.Invoke(() =>
{
ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
});
return;
}
string originalFileName = Path.GetFileNameWithoutExtension(currentVideo);
string extension = Path.GetExtension(currentVideo);
// 生成唯一的文件名:原始名称+方案号+生成日期+序号
string modeSuffix = ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame ? "1" : "2";
string dateStr = DateTime.Now.ToString("MMdd");
string _tmpFileName = $"{originalFileName}_{dateStr}{modeSuffix}{currentIndex:D4}{extension}";
string outPath = Path.Combine(_tmpPath, "out");
LogUtils.Info($"准备创建输出目录:{outPath}");
try
{
if (!Directory.Exists(outPath))
{
Directory.CreateDirectory(outPath);
LogUtils.Info($"已创建输出目录:{outPath}");
}
}
catch (Exception ex)
{
LogUtils.Error($"创建输出目录失败:{outPath}", ex);
Interlocked.Increment(ref completedTasks);
ExtractWindowModel.Dispatcher.Invoke(() =>
{
ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})";
});
return;
}
string outputPath = Path.Combine(outPath, _tmpFileName);
LogUtils.Info($"开始抽帧:输入={currentVideo}, 输出={outputPath}");
// 定义显示用的变量(提前定义,避免作用域问题)
// 显示完整的文件名让DataGrid的列宽和ToolTip来处理显示
string displayFileName = originalFileName;
int currentCompleted;
double progressPercent;
// 获取原始文件大小
long originalSize = 0;
if (File.Exists(currentVideo))
{
FileInfo originalFileInfo = new FileInfo(currentVideo);
originalSize = originalFileInfo.Length;
Interlocked.Add(ref totalOriginalSize, originalSize);
}
// 如果文件已存在,跳过(不覆盖)
if (File.Exists(outputPath))
{
LogUtils.Info($"文件已存在,跳过:{outputPath}");
Interlocked.Increment(ref skipCount);
Interlocked.Increment(ref completedTasks);
currentCompleted = completedTasks;
progressPercent = currentCompleted * 100.0 / totalTasks;
ExtractWindowModel.Dispatcher.Invoke(() =>
{
var taskItem = new Models.ExtractTaskItem
{
Index = currentIndex.ToString(),
FileName = displayFileName,
FullFileName = _tmpFileName,
Status = "跳过",
OriginalSize = "--",
OutputSize = "--",
SizeChange = "--",
Duration = "--",
Progress = $"{progressPercent:F1}%"
};
ExtractWindowModel.TaskItems.Add(taskItem);
});
return;
}
// 先检查视频时长和获取视频信息
IMediaAnalysis? mediaInfo = null;
double totalDuration = 0;
try
{
mediaInfo = await FFProbe.AnalyseAsync(currentVideo);
totalDuration = mediaInfo.Duration.TotalSeconds;
if (totalDuration < 20 && ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame)
{
string errorMsg = $"视频时长太短:{currentVideo}{totalDuration:F2}秒需要至少20秒";
LogUtils.Error(errorMsg);
errorMessages.Add(errorMsg);
Interlocked.Increment(ref completedTasks);
currentCompleted = completedTasks;
progressPercent = currentCompleted * 100.0 / totalTasks;
ExtractWindowModel.Dispatcher.Invoke(() =>
{
var taskItem = new Models.ExtractTaskItem
{
Index = currentIndex.ToString(),
FileName = displayFileName,
FullFileName = _tmpFileName,
Status = "失败",
OriginalSize = "--",
OutputSize = "--",
SizeChange = "--",
Duration = "--",
Progress = $"{progressPercent:F1}%"
};
ExtractWindowModel.TaskItems.Add(taskItem);
});
return;
}
}
catch (Exception ex)
{
LogUtils.Error($"检查视频时长失败:{currentVideo}", ex);
// 继续处理,让后续方法来处理错误
}
// 记录开始时间
DateTime taskStartTime = DateTime.Now;
// 根据选择的处理方式调用相应方法
bool success = false;
if (ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame)
{
// 方案1随机删除一个非关键帧传入index确保每次位置不同
success = await VideoProcess.RemoveNonKeyFrameAsync(currentVideo, outputPath, currentIndex);
}
else if (ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.AddTransparentImage)
{
// 方案2在随机位置添加透明图
success = await VideoProcess.AddTransparentImageAsync(currentVideo, outputPath);
}
else
{
// 默认使用原来的方法(保留兼容性)
success = await VideoProcess.RemoveFrameRandomeAsync(currentVideo, outputPath);
}
// 计算处理时间
TimeSpan taskDuration = DateTime.Now - taskStartTime;
// 更新完成计数
Interlocked.Increment(ref completedTasks);
currentCompleted = completedTasks;
progressPercent = currentCompleted * 100.0 / totalTasks;
// 再次检查文件是否存在
if (File.Exists(outputPath))
{
FileInfo outputFileInfo = new FileInfo(outputPath);
long outputSize = outputFileInfo.Length;
Interlocked.Add(ref totalOutputSize, outputSize);
Interlocked.Increment(ref successCount);
double originalMB = originalSize / 1024.0 / 1024.0;
double outputMB = outputSize / 1024.0 / 1024.0;
double sizeDiff = outputSize - originalSize;
double sizeDiffMB = Math.Abs(sizeDiff) / 1024.0 / 1024.0;
string sizeChange = sizeDiff > 0 ? $"+{sizeDiffMB:F2}" : sizeDiff < 0 ? $"-{sizeDiffMB:F2}" : "0";
string sizeChangeMB = sizeChange + "MB";
string originalSizeStr = $"{originalMB:F2}MB";
string outputSizeStr = $"{outputMB:F2}MB";
string durationStr = $"{taskDuration.TotalSeconds:F1}s";
successMessages.Add($"{currentVideo} (第{currentIndex}个) - 成功");
LogUtils.Info($"处理成功:{currentVideo} -> {outputPath}, 大小={outputMB:F2}MB, 耗时={taskDuration.TotalSeconds:F2}秒");
// 更新表格行
ExtractWindowModel.Dispatcher.Invoke(() =>
{
var taskItem = new Models.ExtractTaskItem
{
Index = currentIndex.ToString(),
FileName = displayFileName,
FullFileName = _tmpFileName,
Status = "成功",
OriginalSize = originalSizeStr,
OutputSize = outputSizeStr,
SizeChange = sizeChangeMB,
Duration = durationStr,
Progress = $"{progressPercent:F1}%"
};
ExtractWindowModel.TaskItems.Add(taskItem);
});
}
else if (success)
{
LogUtils.Warn($"处理返回成功但文件不存在:{outputPath}");
string errorMsg = $"{currentVideo} (第{currentIndex}个) - 返回成功但文件不存在";
errorMessages.Add(errorMsg);
ExtractWindowModel.Dispatcher.Invoke(() =>
{
var taskItem = new Models.ExtractTaskItem
{
Index = currentIndex.ToString(),
FileName = displayFileName,
FullFileName = _tmpFileName,
Status = "警告",
OriginalSize = "--",
OutputSize = "--",
SizeChange = "--",
Duration = "--",
Progress = $"{progressPercent:F1}%"
};
ExtractWindowModel.TaskItems.Add(taskItem);
});
}
else
{
string errorMsg = $"{currentVideo} (第{currentIndex}个) - 处理失败";
LogUtils.Error($"{errorMsg} -> {outputPath}");
errorMessages.Add(errorMsg);
ExtractWindowModel.Dispatcher.Invoke(() =>
{
var taskItem = new Models.ExtractTaskItem
{
Index = currentIndex.ToString(),
FileName = displayFileName,
FullFileName = _tmpFileName,
Status = "失败",
OriginalSize = "--",
OutputSize = "--",
SizeChange = "--",
Duration = "--",
Progress = $"{progressPercent:F1}%"
};
ExtractWindowModel.TaskItems.Add(taskItem);
});
}
}
catch (Exception ex)
{
// 在异常处理中重新获取文件名等信息
string originalFileNameForError = Path.GetFileNameWithoutExtension(currentVideo);
string errorMsg = $"抽帧异常:{currentVideo} (第{currentIndex}个) - {ex.Message}";
LogUtils.Error($"抽帧失败:{currentVideo} (第{currentIndex}个)", ex);
errorMessages.Add(errorMsg);
Interlocked.Increment(ref completedTasks);
int currentCompletedForError = completedTasks;
double progressPercentForError = currentCompletedForError * 100.0 / totalTasks;
string displayFileNameForError = originalFileNameForError.Length > 15 ? originalFileNameForError.Substring(0, 12) + "..." : originalFileNameForError.PadRight(15);
string modeSuffixForError2 = ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame ? "方案1" : "方案2";
string dateStrForError2 = DateTime.Now.ToString("yyyyMMdd");
ExtractWindowModel.Dispatcher.Invoke(() =>
{
var taskItem = new Models.ExtractTaskItem
{
Index = currentIndex.ToString(),
FileName = displayFileNameForError,
FullFileName = $"{originalFileNameForError}_{modeSuffixForError2}_{dateStrForError2}_{currentIndex:D4}{Path.GetExtension(currentVideo)}",
Status = "异常",
OriginalSize = "--",
OutputSize = "--",
SizeChange = "--",
Duration = "--",
Progress = $"{progressPercentForError:F1}%"
};
ExtractWindowModel.TaskItems.Add(taskItem);
});
}
finally
{
semaphore.Release();
}
});
_tasks.Add(_task);
}
}
await Task.WhenAll(_tasks);
// 计算总耗时
TimeSpan totalDuration = DateTime.Now - startTime;
// 统计实际生成的文件数量
int actualFileCount = 0;
string outputDir = "";
if (ExtractWindowModel.videos.Length > 0)
{
string firstVideo = ExtractWindowModel.videos[0];
string videoDir = Path.GetDirectoryName(firstVideo) ?? "";
outputDir = Path.Combine(videoDir, "out");
if (Directory.Exists(outputDir))
{
actualFileCount = Directory.GetFiles(outputDir, "*.mp4").Length;
LogUtils.Info($"输出目录 {outputDir} 中共有 {actualFileCount} 个视频文件");
}
}
// 构建最终详细信息
double totalOriginalMB = totalOriginalSize / 1024.0 / 1024.0;
double totalOutputMB = totalOutputSize / 1024.0 / 1024.0;
double totalSizeDiff = totalOutputSize - totalOriginalSize;
double totalSizeDiffMB = Math.Abs(totalSizeDiff) / 1024.0 / 1024.0;
string totalSizeChange = totalSizeDiff > 0 ? $"+{totalSizeDiffMB:F2}MB" : totalSizeDiff < 0 ? $"-{totalSizeDiffMB:F2}MB" : "0MB";
string summaryInfo = $"\n处理完成\n" +
$"处理方案:{modeText} | 总任务数:{totalTasks} 个\n" +
$"成功数量:{successCount} 个 | 失败数量:{errorMessages.Count} 个 | 跳过数量:{skipCount} 个\n" +
$"实际生成:{actualFileCount} 个文件\n" +
$"总原始大小:{totalOriginalMB:F2}MB | 总输出大小:{totalOutputMB:F2}MB | 大小变化:{totalSizeChange}\n" +
$"总耗时:{totalDuration.TotalSeconds:F1}秒 ({totalDuration.TotalMinutes:F1}分钟) | 平均速度:{(successCount > 0 ? totalDuration.TotalSeconds / successCount : 0):F2}秒/个\n" +
$"输出目录:{outputDir}\n" +
$"完成时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}";
ExtractWindowModel.Dispatcher.Invoke(() =>
{
ExtractWindowModel.HelpInfo += $"\n{summaryInfo}";
// 如果有错误信息,显示汇总
if (errorMessages.Count > 0)
{
ExtractWindowModel.HelpInfo += $"\n错误信息共{errorMessages.Count}个):";
foreach (var error in errorMessages)
{
ExtractWindowModel.HelpInfo += $"\n • {error}";
}
}
ExtractWindowModel.IsStart = false;
ExtractWindowModel.IsCanOperate = true;
});
LogUtils.Info($"处理完成,成功={successCount}, 失败={errorMessages.Count}, 跳过={skipCount}, 总耗时={totalDuration.TotalSeconds:F1}秒");
});
}
},
BtnStartVideoModifyCommand = new Command()
{ {
DoExcue = obj => DoExcue = obj =>
{ {
ExtractWindowModel.HelpInfo = ""; ExtractWindowModel.HelpInfo = "";
Task.Run(action: () =>
SemaphoreSlim semaphore = new(10); // Limit to 3 threads
List<Task> _tasks = [];
ExtractWindowModel.videos.ForEach(async (video) =>
{ {
await semaphore.WaitAsync(); // Wait when more than 3 threads are running ExtractWindowModel.videos.ForEach(async (video) =>
var _task = Task.Run(async () =>
{ {
try // 实例化并调用
{ var remover = new VideoProcess();
// 实例化并调用 // 删除4秒处的帧需根据实际帧位置调整
var remover = new VideoProcess(); string _tmpPath = Path.GetDirectoryName(video) ?? "";
// 删除4秒处的帧需根据实际帧位置调整 string _tmpFileName = $"抽帧视频-{Path.GetFileName(video)}";
string _tmpPath = Path.GetDirectoryName(video) ?? ""; await VideoProcess.RemoveFrameRandomeAsync(video, $"{_tmpPath}\\{_tmpFileName}");
string _tmpFileName = $"{(new Random()).Next(10000, 99999)}{Path.GetFileName(video)}";
string outPath = Path.Combine(_tmpPath, "out");
if (!Path.Exists(outPath))
{
Directory.CreateDirectory(outPath);
}
VideoProcess.ModifyByMetadata(video, $"{_tmpPath}\\out\\modify{_tmpFileName}");
}
finally
{
semaphore.Release(); // Work is done, signal to semaphore that more work is possible
}
}); });
_tasks.Add(_task);
}); });
Task.WhenAll(_tasks).ContinueWith((task) => ExtractWindowModel.HelpInfo = "全部完成!";
{
ExtractWindowModel.HelpInfo = "全部完成!";
});
} }
} }
}; };

View File

@ -9,11 +9,6 @@ using FFMpegCore.Enums;
using static VideoConcat.Models.VideoModel; using static VideoConcat.Models.VideoModel;
using System.Windows.Threading; using System.Windows.Threading;
using System.Windows; using System.Windows;
using System.Diagnostics;
using System.Linq;
// 明确使用 WPF 的 OpenFileDialog避免与 Windows Forms 冲突
using OpenFileDialog = Microsoft.Win32.OpenFileDialog;
using System.Windows.Forms;
namespace VideoConcat.ViewModels namespace VideoConcat.ViewModels
{ {
@ -51,8 +46,8 @@ namespace VideoConcat.ViewModels
{ {
DoExcue = obj => DoExcue = obj =>
{ {
System.Windows.Forms.FolderBrowserDialog folderBrowserDialog = new(); FolderBrowserDialog folderBrowserDialog = new();
if (folderBrowserDialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) if (folderBrowserDialog.ShowDialog() == DialogResult.OK)
{ {
VideoModel.FolderPath = folderBrowserDialog.SelectedPath; VideoModel.FolderPath = folderBrowserDialog.SelectedPath;
LogUtils.Info($"获取视频文件夹,视频路径:{VideoModel.FolderPath}"); LogUtils.Info($"获取视频文件夹,视频路径:{VideoModel.FolderPath}");
@ -65,7 +60,7 @@ namespace VideoConcat.ViewModels
{ {
DoExcue = obj => DoExcue = obj =>
{ {
// 创建一个 OpenFileDialog 实例(使用 WPF 的 OpenFileDialog // 创建一个 OpenFileDialog 实例
OpenFileDialog openFileDialog = new() OpenFileDialog openFileDialog = new()
{ {
// 设置文件对话框的标题 // 设置文件对话框的标题
@ -75,12 +70,12 @@ namespace VideoConcat.ViewModels
}; };
// 显示文件对话框并获取结果WPF 返回 bool? // 显示文件对话框并获取结果
bool? result = openFileDialog.ShowDialog(); DialogResult result = openFileDialog.ShowDialog();
// 检查用户是否点击了打开按钮 // 检查用户是否点击了打开按钮
if (result == true) if (result == DialogResult.OK)
{ {
// 获取用户选择的文件路径列表 // 获取用户选择的文件路径列表
VideoModel.AuditImagePath = openFileDialog.FileName; VideoModel.AuditImagePath = openFileDialog.FileName;
@ -190,58 +185,18 @@ namespace VideoConcat.ViewModels
LogUtils.Info("开始拼接视频"); LogUtils.Info("开始拼接视频");
// 预先创建所有ConcatVideo项用于显示进度
VideoModel.Dispatcher.Invoke(() =>
{
for (int i = 0; i < result.Count; i++)
{
List<string> combination = result[i];
string firstVideoPath = combination[0];
string firstVideoName = Path.GetFileNameWithoutExtension(firstVideoPath);
string _tempFileName = $"{firstVideoName}_{i + 1:D4}.mp4";
string _outPutName = Path.Combine($"{VideoModel.FolderPath}", "output", _tempFileName);
VideoModel.ConcatVideos.Add(new ConcatVideo()
{
Index = i + 1,
FileName = _tempFileName,
FilePath = _outPutName,
Size = "-",
Seconds = 0,
Status = "处理中",
Progress = "0%",
});
}
});
List<Task> taskList = []; List<Task> taskList = [];
semaphore = new(10); semaphore = new(10);
for (int combinationIndex = 0; combinationIndex < result.Count; combinationIndex++) foreach (List<string> combination in result)
{ {
List<string> combination = result[combinationIndex];
int currentIndex = combinationIndex + 1; // 序号从1开始在闭包外计算
await semaphore.WaitAsync(); await semaphore.WaitAsync();
var _task = Task.Run(() => var _task = Task.Run(() =>
{ {
// 获取第一个视频的文件名(不含扩展名) string _tempFileName = $"{DateTime.Now:yyyyMMddHHmmss}{random.Next(100000, 999999)}.mp4";
string firstVideoPath = combination[0];
string firstVideoName = Path.GetFileNameWithoutExtension(firstVideoPath);
string _tempFileName = $"{firstVideoName}_{currentIndex:D4}.mp4";
string _outPutName = Path.Combine($"{VideoModel.FolderPath}", "output", _tempFileName); ; string _outPutName = Path.Combine($"{VideoModel.FolderPath}", "output", _tempFileName); ;
// 更新进度为"准备中"
VideoModel.Dispatcher.Invoke(() =>
{
var videoItem = VideoModel.ConcatVideos.FirstOrDefault(v => v.Index == currentIndex);
if (videoItem != null)
{
videoItem.Progress = "准备中...";
videoItem.Status = "处理中";
// 进度值会在UpdateProgressValue中自动更新
}
});
try try
{ {
@ -254,37 +209,9 @@ namespace VideoConcat.ViewModels
bool _isSuccess = false; bool _isSuccess = false;
// 更新进度为"拼接中"
VideoModel.Dispatcher.Invoke(() =>
{
var videoItem = VideoModel.ConcatVideos.FirstOrDefault(v => v.Index == currentIndex);
if (videoItem != null)
{
videoItem.Progress = "0%";
videoItem.Status = "拼接中";
}
});
try try
{ {
// 计算总时长用于进度计算
double totalDuration = 0;
try
{
foreach (var videoPath in combination)
{
var analysis = FFProbe.Analyse(videoPath);
totalDuration += analysis.Duration.TotalSeconds;
}
}
catch
{
totalDuration = 100; // 默认值
}
int currentIndexForProgress = currentIndex;
double processedDuration = 0;
_isSuccess = FFMpegArguments _isSuccess = FFMpegArguments
.FromConcatInput(temporaryVideoParts) .FromConcatInput(temporaryVideoParts)
.OutputToFile(_outPutName, true, options => options .OutputToFile(_outPutName, true, options => options
@ -296,43 +223,6 @@ namespace VideoConcat.ViewModels
.WithAudioSamplingRate(44100) // 强制采样率 .WithAudioSamplingRate(44100) // 强制采样率
.WithCustomArgument("-movflags +faststart -analyzeduration 100M -probesize 100M") .WithCustomArgument("-movflags +faststart -analyzeduration 100M -probesize 100M")
) )
.NotifyOnOutput((output) =>
{
// 解析FFmpeg输出获取进度
// FFmpeg输出格式: time=00:00:05.00 bitrate=...
if (output.Contains("time="))
{
try
{
int timeIndex = output.IndexOf("time=");
if (timeIndex >= 0)
{
string timeStr = output.Substring(timeIndex + 5, 11); // time=HH:MM:SS.mm
if (TimeSpan.TryParse(timeStr, out TimeSpan currentTime))
{
processedDuration = currentTime.TotalSeconds;
int progressPercent = totalDuration > 0
? (int)((processedDuration / totalDuration) * 100)
: 0;
progressPercent = Math.Min(100, Math.Max(0, progressPercent));
VideoModel.Dispatcher.Invoke(() =>
{
var videoItem = VideoModel.ConcatVideos.FirstOrDefault(v => v.Index == currentIndexForProgress);
if (videoItem != null)
{
videoItem.Progress = $"{progressPercent}%";
}
});
}
}
}
catch
{
// 忽略解析错误
}
}
})
.ProcessSynchronously(); .ProcessSynchronously();
} }
catch (Exception ex) catch (Exception ex)
@ -343,35 +233,20 @@ namespace VideoConcat.ViewModels
//bool _isSuccess = FFMpeg.Join(_outPutName, [.. combination]); //bool _isSuccess = FFMpeg.Join(_outPutName, [.. combination]);
// 更新拼接结果
VideoModel.Dispatcher.Invoke(() => VideoModel.Dispatcher.Invoke(() =>
{ {
var videoItem = VideoModel.ConcatVideos.FirstOrDefault(v => v.Index == currentIndex); IMediaAnalysis _mediaInfo = FFProbe.Analyse(_outPutName);
if (videoItem != null && File.Exists(_outPutName)) FileInfo fileInfo = new(_outPutName);
{
try
{
IMediaAnalysis _mediaInfo = FFProbe.Analyse(_outPutName);
FileInfo fileInfo = new(_outPutName);
videoItem.FilePath = _outPutName; VideoModel.ConcatVideos.Add(new ConcatVideo()
videoItem.Size = $"{fileInfo.Length / 1024 / 1024}MB";
videoItem.Seconds = ((int)_mediaInfo.Duration.TotalSeconds);
videoItem.Status = _isSuccess ? "拼接成功" : "拼接失败";
videoItem.Progress = _isSuccess ? "100%" : "失败";
}
catch (Exception ex)
{
videoItem.Status = "拼接失败";
videoItem.Progress = "失败";
LogUtils.Error($"分析视频信息失败:{_outPutName}", ex);
}
}
else if (videoItem != null)
{ {
videoItem.Status = "拼接失败"; Index = VideoModel.ConcatVideos.Count + 1,
videoItem.Progress = "失败"; FileName = _tempFileName,
} Size = $"{fileInfo.Length / 1024 / 1024}MB",
Seconds = ((int)_mediaInfo.Duration.TotalSeconds),
Status = _isSuccess ? "拼接成功" : "拼接失败",
Progress = "100%",
});
}); });
@ -383,10 +258,7 @@ namespace VideoConcat.ViewModels
// 配置 FFmpeg 二进制文件位置(如果 FFmpeg 不在系统路径中) // 配置 FFmpeg 二进制文件位置(如果 FFmpeg 不在系统路径中)
// GlobalFFOptions.Configure(new FFOptions { BinaryFolder = "path/to/ffmpeg/bin" }); // GlobalFFOptions.Configure(new FFOptions { BinaryFolder = "path/to/ffmpeg/bin" });
// 使用相同的命名规则,添加 _img 后缀表示带水印 string _outPutNameImg = $"{VideoModel.FolderPath}\\output\\{DateTime.Now:yyyyMMddHHmmss}{random.Next(100000, 999999)}.mp4";
string firstVideoPathName = combination[0];
string firstVideoNameHas = Path.GetFileNameWithoutExtension(firstVideoPathName);
string _outPutNameImg = Path.Combine($"{VideoModel.FolderPath}", "output", $"{firstVideoNameHas}_{currentIndex:D4}_img.mp4");
string _customArg = "-filter_complex \"[0:v][1:v] overlay=0:H-h\" "; string _customArg = "-filter_complex \"[0:v][1:v] overlay=0:H-h\" ";
// 使用 FFMpegArguments 构建命令 // 使用 FFMpegArguments 构建命令
@ -432,51 +304,11 @@ namespace VideoConcat.ViewModels
{ {
//结束时间 //结束时间
DateTime endTime = DateTime.Now; DateTime endTime = DateTime.Now;
double elapsedSeconds = (endTime - startTime).TotalSeconds; LogUtils.Info($"所有视频拼接完成,用时{(endTime - startTime).TotalSeconds}秒");
// 统计成功和失败的数量
int successCount = 0;
int failCount = 0;
string outputDir = "";
VideoModel.Dispatcher.Invoke(() =>
{
successCount = VideoModel.ConcatVideos.Count(v => v.Status == "拼接成功");
failCount = VideoModel.ConcatVideos.Count(v => v.Status == "拼接失败");
if (VideoModel.ConcatVideos.Count > 0)
{
var firstVideo = VideoModel.ConcatVideos.FirstOrDefault(v => !string.IsNullOrEmpty(v.FilePath));
if (firstVideo != null && !string.IsNullOrEmpty(firstVideo.FilePath))
{
outputDir = Path.GetDirectoryName(firstVideo.FilePath) ?? "";
}
}
});
LogUtils.Info($"所有视频拼接完成,用时{elapsedSeconds:F2}秒,成功{successCount}个,失败{failCount}个");
VideoModel.IsStart = false; VideoModel.IsStart = false;
VideoCombine.Cleanup(VideoCombine.mustClearPath); VideoCombine.Cleanup(VideoCombine.mustClearPath);
MessageBox.Show("所有视频拼接完成");
// 构建提示信息
string message = $"所有视频拼接完成!\n\n";
message += $"成功:{successCount} 个\n";
if (failCount > 0)
{
message += $"失败:{failCount} 个\n";
}
message += $"用时:{elapsedSeconds:F2} 秒\n";
if (!string.IsNullOrEmpty(outputDir))
{
message += $"\n输出目录\n{outputDir}";
}
VideoModel.Dispatcher.Invoke(() =>
{
MessageBox.Show(message, "拼接完成", MessageBoxButton.OK, MessageBoxImage.Information);
});
VideoCombine.mustClearPath = []; VideoCombine.mustClearPath = [];
}); });
}); });

View File

@ -23,65 +23,20 @@
<Grid> <Grid>
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition> <RowDefinition Height="40"></RowDefinition>
<RowDefinition Height="*"></RowDefinition> <RowDefinition Height="*"></RowDefinition>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Border> <Border>
<WrapPanel Orientation="Horizontal" VerticalAlignment="Center"> <WrapPanel>
<Label VerticalAlignment="Center" Width="100" FontSize="16" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" Style="{StaticResource MaterialDesignLabel}" VerticalContentAlignment="Stretch">视频文件夹:</Label> <Label VerticalAlignment="Center" Width="100" FontSize="16" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" Style="{StaticResource MaterialDesignLabel}" VerticalContentAlignment="Stretch">视频文件夹:</Label>
<TextBox Grid.Column="1" Width="500" Name="FoldPath" Text="{Binding ExtractWindowModel.FolderPath,Mode=TwoWay}" IsReadOnly="True" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" FontSize="16" VerticalContentAlignment="Center" materialDesign:HintAssist.Hint="选择包含需要处理的视频文件夹"/> <TextBox Grid.Column="1" Width="650" Name="FoldPath" Text="{Binding ExtractWindowModel.FolderPath,Mode=TwoWay}" IsReadOnly="True" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" FontSize="16" VerticalContentAlignment="Center" materialDesign:HintAssist.Hint="选择包含需要处理的视频文件夹"/>
<Button Grid.Column="2" Width="80" Content="选择" FontSize="16" Command="{Binding ExtractWindowModel.BtnOpenFolderCommand}" Background="#40568D" BorderBrush="#7F7F7F" BorderThickness="2" ToolTip="选择含有视频的主文件夹" Style="{StaticResource MaterialDesignRaisedButton}" Margin="5,2" IsEnabled="{Binding ExtractWindowModel.IsCanOperate}"/> <Button Grid.Column="2" Width="100" Content="选择" FontSize="16" Command="{Binding ExtractWindowModel.BtnOpenFolderCommand}" Background="#40568D" BorderBrush="#7F7F7F" BorderThickness="2" ToolTip="选择含有视频的主文件夹" Style="{StaticResource MaterialDesignRaisedButton}" Margin="5,2" IsEnabled="{Binding ExtractWindowModel.IsCanOperate}"/>
<Label VerticalAlignment="Center" Width="80" FontSize="16" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" Style="{StaticResource MaterialDesignLabel}" VerticalContentAlignment="Stretch">生成个数:</Label> <Button Grid.Column="3" Width="100" Content="处理" FontSize="16" Command="{Binding ExtractWindowModel.BtnStartVideoConcatCommand}" Background="#3B94FE" BorderBrush="#7F7F7F" BorderThickness="2" ToolTip="开始处理视频" Style="{StaticResource MaterialDesignRaisedButton}" Margin="5,2,0,2" IsEnabled="{Binding ExtractWindowModel.CanStart}"/>
<TextBox Width="100" Text="{Binding ExtractWindowModel.ExtractCount,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" FontSize="16" VerticalContentAlignment="Center" ToolTip="输入要生成的抽帧视频个数" materialDesign:HintAssist.Hint="个数" IsEnabled="{Binding ExtractWindowModel.IsCanOperate}"/>
<Label VerticalAlignment="Center" Width="80" FontSize="16" Margin="5,2" BorderBrush="#7F7F7F" BorderThickness="2" Style="{StaticResource MaterialDesignLabel}" VerticalContentAlignment="Stretch">处理方式:</Label>
<RadioButton Content="方案1" IsChecked="{Binding ExtractWindowModel.IsDeleteFrameMode,Mode=TwoWay}" Margin="5,2" FontSize="14" VerticalAlignment="Center" IsEnabled="{Binding ExtractWindowModel.IsCanOperate}" GroupName="ExtractMode" ToolTip="随机删除一个非关键帧"/>
<RadioButton Content="方案2" IsChecked="{Binding ExtractWindowModel.IsAddTransparentImageMode,Mode=TwoWay}" Margin="5,2" FontSize="14" VerticalAlignment="Center" IsEnabled="{Binding ExtractWindowModel.IsCanOperate}" GroupName="ExtractMode" ToolTip="在随机位置添加一个肉眼无法感知的小像素透明图"/>
<Button Grid.Column="3" Width="120" Content="{Binding ExtractWindowModel.ExtractButtonText}" FontSize="16" Command="{Binding ExtractWindowModel.BtnStartVideoConcatCommand}" Background="#3B94FE" BorderBrush="#7F7F7F" BorderThickness="2" ToolTip="开始处理视频" Style="{StaticResource MaterialDesignRaisedButton}" Margin="5,2,0,2" IsEnabled="{Binding ExtractWindowModel.CanExtractFrame}"/>
<Button Grid.Column="4" Width="120" Content="修改meta" FontSize="16" Command="{Binding ExtractWindowModel.BtnStartVideoModifyCommand}" Background="#3B94FE" BorderBrush="#7F7F7F" BorderThickness="2" ToolTip="开始修改视频元数据" Style="{StaticResource MaterialDesignRaisedButton}" Margin="5,2,0,2" IsEnabled="{Binding ExtractWindowModel.CanModify}"/>
</WrapPanel> </WrapPanel>
</Border> </Border>
<Border Grid.Row="1"> <Border Grid.Row="1">
<Grid> <TextBlock Text="{Binding ExtractWindowModel.HelpInfo,Mode=TwoWay}"></TextBlock>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="{Binding ExtractWindowModel.HelpInfo,Mode=TwoWay}" Margin="5" TextWrapping="Wrap"/>
<DataGrid Grid.Row="1"
ItemsSource="{Binding ExtractWindowModel.TaskItems}"
AutoGenerateColumns="False"
IsReadOnly="True"
HeadersVisibility="Column"
GridLinesVisibility="All"
AlternatingRowBackground="LightGray"
Margin="5"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
ColumnWidth="*">
<DataGrid.Columns>
<DataGridTextColumn Header="序号" Binding="{Binding Index}" Width="50"/>
<DataGridTextColumn Header="文件名" Binding="{Binding FileName}" Width="4*" MinWidth="350">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="TextTrimming" Value="None"/>
<Setter Property="ToolTip" Value="{Binding FullFileName}"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Margin" Value="2,0"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="状态" Binding="{Binding Status}" Width="70"/>
<DataGridTextColumn Header="原始大小" Binding="{Binding OriginalSize}" Width="90"/>
<DataGridTextColumn Header="输出大小" Binding="{Binding OutputSize}" Width="90"/>
<DataGridTextColumn Header="大小变化" Binding="{Binding SizeChange}" Width="90"/>
<DataGridTextColumn Header="耗时" Binding="{Binding Duration}" Width="70"/>
<DataGridTextColumn Header="进度" Binding="{Binding Progress}" Width="70"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
</Border> </Border>
</Grid> </Grid>
</Border> </Border>

View File

@ -1,6 +1,4 @@
using System.Net; using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Windows; using System.Windows;
using VideoConcat.Common.Api.Base; using VideoConcat.Common.Api.Base;
using VideoConcat.Common.Tools; using VideoConcat.Common.Tools;
@ -40,37 +38,6 @@ namespace VideoConcat.Views
WPFDevelopers.Controls.MessageBox.Show("请输入用户名或者密码!"); WPFDevelopers.Controls.MessageBox.Show("请输入用户名或者密码!");
return; return;
} }
// super 用户使用密码 hash 验证
if (_userName == "super")
{
// 计算输入密码的 SHA256 hash
string inputPasswordHash = ComputeSha256Hash(_password);
// 计算正确密码 "080500" 的 SHA256 hash 进行比较
string correctPasswordHash = "15a8819f8c909ea25892eb0d86279f1768c6d79861403c000ef79cef5f2402f9";
if (inputPasswordHash == correctPasswordHash)
{
if (_isChecked)
{
Config.UpdateSettingString("userName", _userName);
// 不保存密码
Config.UpdateSettingString("password", "");
Config.UpdateSettingString("isRemember", "true");
}
else
{
Config.UpdateSettingString("userName", "");
Config.UpdateSettingString("password", "");
Config.UpdateSettingString("isRemember", "false");
}
new MainWindow().Show();
Close();
return;
}
}
ApiResponse<UserLoginResponse> res = await SystemApi.LoginAsync<UserLoginResponse>(_userName, _password); ApiResponse<UserLoginResponse> res = await SystemApi.LoginAsync<UserLoginResponse>(_userName, _password);
if (res.Code != 0) if (res.Code != 0)
{ {
@ -95,21 +62,6 @@ namespace VideoConcat.Views
Close(); Close();
} }
} }
/// <summary>
/// 计算字符串的 SHA256 hash 值
/// </summary>
/// <param name="input">输入字符串</param>
/// <returns>SHA256 hash 值(小写)</returns>
private string ComputeSha256Hash(string input)
{
using (SHA256 sha256 = SHA256.Create())
{
byte[] bytes = Encoding.UTF8.GetBytes(input);
byte[] hash = sha256.ComputeHash(bytes);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
}
} }
} }

View File

@ -11,7 +11,7 @@
mc:Ignorable="d" mc:Ignorable="d"
Width="1100" Height="800" WindowStartupLocation="CenterScreen" Width="1100" Height="800" WindowStartupLocation="CenterScreen"
ResizeMode="CanMinimize" ResizeMode="NoResize"
Title="工具"> Title="工具">
<Window.Resources> <Window.Resources>
<Style x:Key="BottomButton" TargetType="Button"> <Style x:Key="BottomButton" TargetType="Button">

View File

@ -13,8 +13,7 @@ namespace VideoConcat.Views
/// </summary> /// </summary>
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
private VideoWindow? _videoWindow;
private ExtractWindow? _extractWindow;
public MainWindow() public MainWindow()
{ {
@ -30,10 +29,10 @@ namespace VideoConcat.Views
SetupGridColumns(mainGrid); SetupGridColumns(mainGrid);
// 2. 实例化已有视图(或获取已存在的视图实例) // 2. 实例化已有视图(或获取已存在的视图实例)
_videoWindow = new VideoWindow(); // 这里是已有视图的实例 var existingView = new VideoWindow(); // 这里是已有视图的实例
// 3. 将视图添加到指定列中例如第1列索引为1 // 3. 将视图添加到指定列中例如第1列索引为1
AddViewToColumn(mainGrid, _videoWindow); AddViewToColumn(mainGrid, existingView);
} }
/// <summary> /// <summary>
@ -75,60 +74,24 @@ namespace VideoConcat.Views
{ {
if (radioButton.Name == "extract") if (radioButton.Name == "extract")
{ {
// 切换到extract tab检查是否有正在进行的处理
if (_extractWindow != null)
{
var viewModel = _extractWindow.DataContext as ViewModels.ExtractWindowViewModel;
if (viewModel != null && viewModel.ExtractWindowModel.IsStart)
{
// 有正在进行的处理,允许切换,因为用户可能想查看进度
// 不做任何阻止操作
}
}
SetupGridColumns(mainGrid); SetupGridColumns(mainGrid);
// 2. 实例化已有视图(或获取已存在的视图实例)
// 如果已存在实例,重用;否则创建新实例 var existingView = new ExtractWindow(); // 这里是已有视图的实例
if (_extractWindow == null)
{
_extractWindow = new ExtractWindow();
}
// 将视图添加到Grid中 // 3. 将视图添加到指定列中例如第1列索引为1
AddViewToColumn(mainGrid, _extractWindow); AddViewToColumn(mainGrid, existingView);
} }
if (radioButton.Name == "video") if (radioButton.Name == "video")
{ {
// 从extract切换到video时检查是否有正在进行的处理 // 1. 为目标Grid创建列定义
if (_extractWindow != null)
{
var viewModel = _extractWindow.DataContext as ViewModels.ExtractWindowViewModel;
if (viewModel != null && viewModel.ExtractWindowModel.IsStart)
{
// 有正在进行的处理,显示提示并阻止切换
System.Windows.MessageBox.Show("当前有正在进行的处理任务,请等待处理完成后再切换!", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
// 恢复extract tab的选中状态
radioButton.IsChecked = false;
var extractRadioButton = FindName("extract") as RadioButton;
if (extractRadioButton != null)
{
extractRadioButton.IsChecked = true;
}
return;
}
}
SetupGridColumns(mainGrid); SetupGridColumns(mainGrid);
// 如果已存在实例,重用;否则创建新实例 // 2. 实例化已有视图(或获取已存在的视图实例)
if (_videoWindow == null) var existingView = new VideoWindow(); // 这里是已有视图的实例
{
_videoWindow = new VideoWindow();
}
// 将视图添加到Grid中 // 3. 将视图添加到指定列中例如第1列索引为1
AddViewToColumn(mainGrid, _videoWindow); AddViewToColumn(mainGrid, existingView);
} }
} }
} }

View File

@ -1,86 +0,0 @@
<Window x:Class="VideoConcat.Views.VideoPreviewWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
mc:Ignorable="d"
Title="视频预览" Height="600" Width="900"
WindowStartupLocation="CenterOwner"
ResizeMode="CanResize">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 标题栏 -->
<Border Grid.Row="0" Background="#40568D" Height="40">
<StackPanel Orientation="Horizontal" Margin="10,0">
<TextBlock Text="视频预览" FontSize="16" Foreground="White" VerticalAlignment="Center" FontWeight="Bold"/>
<TextBlock x:Name="FileNameText" Text="" FontSize="14" Foreground="White" VerticalAlignment="Center" Margin="20,0,0,0"/>
</StackPanel>
</Border>
<!-- 视频播放区域 -->
<Grid Grid.Row="1" Background="Black">
<MediaElement x:Name="VideoPlayer"
LoadedBehavior="Manual"
UnloadedBehavior="Stop"
Stretch="Uniform"
MediaOpened="VideoPlayer_MediaOpened"
MediaEnded="VideoPlayer_MediaEnded"/>
<!-- 播放控制按钮 -->
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Margin="0,0,0,20"
Background="#80000000">
<Button x:Name="PlayPauseButton"
Content="播放"
Width="80"
Height="35"
Margin="5"
Click="PlayPauseButton_Click"
Style="{StaticResource MaterialDesignRaisedButton}"
Background="#40568D"/>
<Button Content="停止"
Width="80"
Height="35"
Margin="5"
Click="StopButton_Click"
Style="{StaticResource MaterialDesignRaisedButton}"
Background="#E54858"/>
<Button Content="打开文件"
Width="100"
Height="35"
Margin="5"
Click="OpenFileButton_Click"
Style="{StaticResource MaterialDesignRaisedButton}"
Background="#40568D"/>
</StackPanel>
</Grid>
<!-- 进度条和时间信息 -->
<Grid Grid.Row="2" Background="#F5F5F5" Height="80">
<StackPanel Margin="10">
<!-- 进度条 -->
<ProgressBar x:Name="ProgressBar"
Height="20"
Margin="0,5"
Minimum="0"
Maximum="100"/>
<!-- 时间信息 -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,5">
<TextBlock x:Name="CurrentTimeText" Text="00:00" FontSize="12" Margin="5,0"/>
<TextBlock Text=" / " FontSize="12" Margin="5,0"/>
<TextBlock x:Name="TotalTimeText" Text="00:00" FontSize="12" Margin="5,0"/>
</StackPanel>
</StackPanel>
</Grid>
</Grid>
</Window>

View File

@ -1,170 +0,0 @@
using System;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using MessageBox = System.Windows.MessageBox;
// 明确使用 WPF 的 OpenFileDialog避免与 Windows Forms 冲突
using OpenFileDialog = Microsoft.Win32.OpenFileDialog;
namespace VideoConcat.Views
{
/// <summary>
/// VideoPreviewWindow.xaml 的交互逻辑
/// </summary>
public partial class VideoPreviewWindow : Window
{
private DispatcherTimer _timer;
private bool _isPlaying = false;
public VideoPreviewWindow()
{
InitializeComponent();
_timer = new DispatcherTimer();
_timer.Interval = TimeSpan.FromMilliseconds(500);
_timer.Tick += Timer_Tick;
}
public void LoadVideo(string filePath)
{
try
{
if (string.IsNullOrEmpty(filePath))
{
MessageBox.Show("视频文件路径为空!", "错误", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
if (!File.Exists(filePath))
{
MessageBox.Show($"视频文件不存在:\n{filePath}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
// 重置播放器状态
VideoPlayer.Stop();
_isPlaying = false;
_timer?.Stop();
// 设置文件名
FileNameText.Text = Path.GetFileName(filePath);
// 创建 URI使用绝对路径
string fullPath = Path.GetFullPath(filePath);
// Uri 构造函数会自动处理本地文件路径
Uri videoUri = new Uri(fullPath, UriKind.Absolute);
// 加载视频MediaOpened 事件已在 XAML 中定义,这里直接设置源)
VideoPlayer.Source = videoUri;
// 由于 MediaOpened 事件在 XAML 中已定义,视频加载完成后会自动触发
// 如果需要立即播放,可以在 MediaOpened 事件处理中处理
}
catch (UriFormatException ex)
{
MessageBox.Show($"视频文件路径格式错误:\n{ex.Message}\n\n文件路径{filePath}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
catch (Exception ex)
{
MessageBox.Show($"加载视频失败:\n{ex.Message}\n\n文件路径{filePath}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void VideoPlayer_MediaOpened(object sender, RoutedEventArgs e)
{
try
{
if (VideoPlayer.NaturalDuration.HasTimeSpan)
{
TotalTimeText.Text = FormatTime(VideoPlayer.NaturalDuration.TimeSpan);
ProgressBar.Maximum = VideoPlayer.NaturalDuration.TimeSpan.TotalSeconds;
}
// 自动开始播放
if (!_isPlaying)
{
VideoPlayer.Play();
_isPlaying = true;
PlayPauseButton.Content = "暂停";
_timer?.Start();
}
}
catch (Exception ex)
{
MessageBox.Show($"媒体打开后处理失败:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private void VideoPlayer_MediaEnded(object sender, RoutedEventArgs e)
{
_isPlaying = false;
PlayPauseButton.Content = "播放";
VideoPlayer.Position = TimeSpan.Zero;
_timer.Stop();
}
private void PlayPauseButton_Click(object sender, RoutedEventArgs e)
{
if (_isPlaying)
{
VideoPlayer.Pause();
_isPlaying = false;
PlayPauseButton.Content = "播放";
}
else
{
VideoPlayer.Play();
_isPlaying = true;
PlayPauseButton.Content = "暂停";
_timer.Start();
}
}
private void StopButton_Click(object sender, RoutedEventArgs e)
{
VideoPlayer.Stop();
_isPlaying = false;
PlayPauseButton.Content = "播放";
_timer.Stop();
CurrentTimeText.Text = "00:00";
ProgressBar.Value = 0;
}
private void OpenFileButton_Click(object sender, RoutedEventArgs e)
{
OpenFileDialog openFileDialog = new OpenFileDialog
{
Filter = "视频文件|*.mp4;*.avi;*.mkv;*.mov;*.wmv|所有文件|*.*",
Title = "选择视频文件"
};
if (openFileDialog.ShowDialog() == true)
{
LoadVideo(openFileDialog.FileName);
}
}
private void Timer_Tick(object sender, EventArgs e)
{
if (VideoPlayer.NaturalDuration.HasTimeSpan)
{
var currentTime = VideoPlayer.Position;
CurrentTimeText.Text = FormatTime(currentTime);
ProgressBar.Value = currentTime.TotalSeconds;
}
}
private string FormatTime(TimeSpan timeSpan)
{
return $"{(int)timeSpan.TotalMinutes:D2}:{timeSpan.Seconds:D2}";
}
protected override void OnClosed(EventArgs e)
{
_timer?.Stop();
VideoPlayer?.Close();
base.OnClosed(e);
}
}
}

View File

@ -6,7 +6,6 @@
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:wd="https://github.com/WPFDevelopersOrg/WPFDevelopers" xmlns:wd="https://github.com/WPFDevelopersOrg/WPFDevelopers"
xmlns:local="clr-namespace:VideoConcat.Views" xmlns:local="clr-namespace:VideoConcat.Views"
xmlns:conversions="clr-namespace:VideoConcat.Conversions"
mc:Ignorable="d" mc:Ignorable="d"
Height="800" Width="1000"> Height="800" Width="1000">
<UserControl.Resources> <UserControl.Resources>
@ -59,27 +58,12 @@
<StackPanel Height="400"> <StackPanel Height="400">
<DataGrid ItemsSource="{Binding VideoModel.ConcatVideos}" AutoGenerateColumns="False" CanUserAddRows="False" CanUserDeleteRows="False" IsReadOnly="True" VerticalScrollBarVisibility="Auto" MaxHeight="400" ScrollViewer.CanContentScroll="True"> <DataGrid ItemsSource="{Binding VideoModel.ConcatVideos}" AutoGenerateColumns="False" CanUserAddRows="False" CanUserDeleteRows="False" IsReadOnly="True" VerticalScrollBarVisibility="Auto" MaxHeight="400" ScrollViewer.CanContentScroll="True">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="序号" Binding="{Binding Index}" Width="60"></DataGridTextColumn> <DataGridTextColumn Header="序号" Binding="{Binding Index}" Width="auto"></DataGridTextColumn>
<DataGridTextColumn Header="文件名" Binding="{Binding FileName}" Width="*"></DataGridTextColumn> <DataGridTextColumn Header="文件名" Binding="{Binding FileName}" Width="auto"></DataGridTextColumn>
<DataGridTextColumn Header="大小MB" Binding="{Binding Size}" Width="100"></DataGridTextColumn> <DataGridTextColumn Header="大小MB" Binding="{Binding Size}" Width="auto"></DataGridTextColumn>
<DataGridTextColumn Header="时长(秒)" Binding="{Binding Seconds}" Width="100"></DataGridTextColumn> <DataGridTextColumn Header="时长(秒)" Binding="{Binding Seconds}" Width="auto"></DataGridTextColumn>
<DataGridTextColumn Header="状态" Binding="{Binding Status}" Width="100"></DataGridTextColumn> <DataGridTextColumn Header="进度" Binding="{Binding Progress}" Width="auto"></DataGridTextColumn>
<DataGridTemplateColumn Header="进度" Width="200"> <DataGridTextColumn Header="状态" Binding="{Binding Status}" Width="auto"></DataGridTextColumn>
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Vertical" Margin="5,2">
<ProgressBar Value="{Binding ProgressValue}"
Maximum="100"
Height="20"
Margin="0,2"/>
<TextBlock Text="{Binding Progress}"
HorizontalAlignment="Center"
FontSize="11"
Margin="0,2"/>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</StackPanel> </StackPanel>

View File

@ -1,97 +0,0 @@
# VideoConcat 项目文档索引
欢迎查阅 VideoConcat 项目文档。本文档目录包含了项目的完整说明和使用指南。
## 📚 文档列表
### 1. [项目文档.md](./项目文档.md)
**全面的项目说明文档**
包含内容:
- 项目概述
- 技术栈介绍
- 项目结构说明
- 主要功能详解
- 核心模块说明
- 数据模型介绍
- 配置说明
- 依赖项列表
- 使用说明
- API 接口文档
**适合**: 想要全面了解项目的开发者、用户和维护人员
### 2. [架构说明.md](./架构说明.md)
**详细的架构和代码结构说明**
包含内容:
- 架构概述
- 各层详细说明View、ViewModel、Model、Service、Tools
- 数据流程图
- 设计模式应用
- 并发处理机制
- 缓存机制
- 错误处理
- 资源管理
- 扩展性设计
- 性能优化建议
**适合**: 需要深入了解代码结构和架构设计的开发者
### 3. [快速开始.md](./快速开始.md)
**快速上手指南**
包含内容:
- 环境准备
- 编译和运行
- 功能使用步骤
- 常见问题解答
- 性能优化建议
- 注意事项
**适合**: 初次使用项目的用户和开发者
## 🚀 快速导航
### 我是新用户
👉 从 [快速开始.md](./快速开始.md) 开始,了解如何运行和使用程序
### 我是开发者
👉 先看 [项目文档.md](./项目文档.md) 了解整体情况,然后阅读 [架构说明.md](./架构说明.md) 深入理解代码
### 我需要了解某个功能
👉 查看 [项目文档.md](./项目文档.md) 中的"主要功能"章节
### 我需要修改代码
👉 阅读 [架构说明.md](./架构说明.md) 了解代码结构和设计模式
## 📋 文档更新记录
- 2025-01-01: 初始文档创建
- 项目文档.md
- 架构说明.md
- 快速开始.md
## 💡 使用建议
1. **首次使用**: 按照 [快速开始.md](./快速开始.md) 的步骤操作
2. **深入了解**: 阅读 [项目文档.md](./项目文档.md) 了解所有功能
3. **开发维护**: 参考 [架构说明.md](./架构说明.md) 理解代码结构
## 📝 文档维护
如果发现文档有误或需要更新,请:
1. 检查代码是否已变更
2. 更新相应的文档章节
3. 更新本文档的更新记录
## 🔗 相关资源
- 项目根目录: [../README.md](../README.md)
- 源代码: 项目根目录下的各个文件夹
- 日志文件: `bin/Debug/net8.0-windows/Log/`
---
**提示**: 所有文档均使用 Markdown 格式编写,可以使用任何 Markdown 阅读器查看。

View File

@ -1,244 +0,0 @@
# VideoConcat 快速开始指南
## 环境准备
### 系统要求
- Windows 10/11 或更高版本
- .NET 8.0 Runtime如果未安装程序会提示下载
### 检查环境
```powershell
# 检查 .NET 版本
dotnet --version
```
如果未安装 .NET 8.0,请访问 [.NET 下载页面](https://dotnet.microsoft.com/download) 下载安装。
## 获取项目
### 方式一:克隆仓库
```bash
git clone <repository-url>
cd VideoConcat
```
### 方式二:下载源码
下载项目压缩包并解压到本地目录。
## 编译项目
### 使用 Visual Studio
1. 打开 `VideoConcat.sln`
2. 选择 Release 或 Debug 配置
3. 点击"生成" → "生成解决方案" (Ctrl+Shift+B)
### 使用命令行
```powershell
# Debug 编译
dotnet build
# Release 编译
dotnet build -c Release
```
## 运行项目
### 方式一Visual Studio
1. 按 F5 或点击"启动"按钮
2. 程序会自动编译并运行
### 方式二:命令行
```powershell
# Debug 运行
dotnet run
# 或直接运行可执行文件
.\bin\Debug\net8.0-windows\VideoConcat.exe
```
### 方式三Release 版本
```powershell
.\bin\Release\net8.0-windows\VideoConcat.exe
```
## 功能使用
### 1. 视频拼接功能
#### 准备工作
准备视频文件,建议的文件夹结构:
```
视频文件夹/
├── 文件夹1/
│ ├── 视频1.mp4
│ ├── 视频2.mp4
│ └── 视频3.mp4
├── 文件夹2/
│ ├── 视频A.mp4
│ └── 视频B.mp4
└── output/ (自动创建,用于存放输出文件)
```
#### 操作步骤
1. **启动程序**
- 运行 VideoConcat.exe
2. **选择功能**
- 在主界面选择"视频"标签
3. **选择文件夹**
- 点击"选择文件夹"按钮
- 选择包含视频子文件夹的目录(不是选择单个视频文件)
4. **选择拼接模式**
- **组合拼接模式**: 从每个文件夹随机选择视频进行组合
- 例如文件夹1有3个视频文件夹2有2个视频
- 可以生成 3×2=6 种组合
- **顺序拼接模式**: 按索引顺序从每个文件夹选择对应位置的视频
- 要求所有文件夹的视频数量相同
- 例如每个文件夹都有3个视频会生成3个组合
5. **设置数量**
- 在"数量"输入框中输入要生成的视频数量
- 数量不能超过最大可生成数量
6. **(可选)添加审核图片**
- 点击"选择审核图片"按钮
- 选择要添加为水印的图片文件
- 图片会叠加在视频底部
7. **开始拼接**
- 点击"开始拼接"按钮
- 程序会显示处理进度
- 完成后在 `output` 文件夹查看结果
#### 输出结果
- 输出位置: `{选择的文件夹}/output/`
- 文件命名: `yyyyMMddHHmmss{随机数}.mp4`
- 如果添加了审核图片,会生成两个文件:
- 原始拼接文件
- 带水印的文件(文件名不同)
### 2. 视频抽帧功能
#### 准备工作
准备要处理的视频文件:
```
视频文件夹/
├── 视频1.mp4
├── 视频2.mp4
└── 视频3.mp4
```
#### 操作步骤
1. **选择功能**
- 在主界面选择"抽帧"标签
2. **选择文件夹**
- 点击"选择文件夹"按钮
- 选择包含视频文件的目录
3. **开始抽帧**
- 点击"开始抽帧"按钮
- 程序会随机删除每个视频中的一帧
- 处理完成后在 `out` 文件夹查看结果
#### 输出结果
- 输出位置: `{选择的文件夹}/out/`
- 文件命名: `{随机数}{原文件名}`
### 3. 视频元数据修改功能
#### 操作步骤
1. **选择功能**
- 在主界面选择"抽帧"标签
2. **选择文件夹**
- 点击"选择文件夹"按钮
- 选择包含视频文件的目录
3. **开始修改**
- 点击"开始修改"按钮
- 程序会修改视频的元数据(添加注释)
- 这会改变文件的 MD5 值,但不改变视频内容
#### 输出结果
- 输出位置: `{选择的文件夹}/out/`
- 文件命名: `modify{随机数}{原文件名}`
## 常见问题
### Q1: 程序无法启动
**A**: 检查是否安装了 .NET 8.0 Runtime。如果未安装程序会提示下载链接。
### Q2: 视频转换失败
**A**:
- 检查视频文件是否损坏
- 检查视频格式是否支持(主要支持 MP4
- 查看日志文件了解详细错误信息
### Q3: 拼接后的视频没有声音
**A**:
- 检查原始视频是否有音频轨道
- 某些编码格式可能需要重新编码音频
### Q4: 处理速度很慢
**A**:
- 视频处理是 CPU 密集型操作,大文件需要较长时间
- 可以关闭其他占用 CPU 的程序
- 检查磁盘空间是否充足
### Q5: 临时文件占用空间
**A**:
- 临时文件存储在系统临时目录
- 程序处理完成后会自动清理
- 如果程序异常退出,可能需要手动清理临时文件
### Q6: 顺序拼接模式提示错误
**A**:
- 确保所有文件夹中的视频数量相同
- 检查文件夹名称是否正确
### Q7: 如何查看日志
**A**:
- 日志文件位置: `bin/Debug/net8.0-windows/Log/`
- 日志文件命名: `log{日期}.log`,例如 `log20251019.log`
- 使用文本编辑器打开查看
## 性能优化建议
1. **使用 SSD 硬盘**: 视频处理涉及大量磁盘 I/OSSD 可以显著提升速度
2. **关闭杀毒软件实时扫描**: 临时文件频繁创建和删除可能触发扫描
3. **确保足够内存**: 建议至少 8GB RAM
4. **关闭其他程序**: 释放 CPU 和内存资源
5. **批量处理**: 一次处理多个文件比逐个处理更高效(程序已实现)
## 注意事项
1. **备份重要文件**: 虽然程序不会修改原始文件,但建议先备份
2. **磁盘空间**: 确保有足够的磁盘空间存储输出文件和临时文件
3. **文件路径**: 避免使用包含特殊字符的文件路径
4. **处理时间**: 视频处理需要时间,请耐心等待,不要强制关闭程序
5. **HEVC 编码**: 抽帧功能会自动将 HEVC 编码转换为 H.264,这需要额外时间
## 技术支持
如遇到问题,可以:
1. 查看日志文件了解详细错误信息
2. 检查视频文件是否正常
3. 确认系统环境是否符合要求
## 下一步
- 阅读 [项目文档.md](./项目文档.md) 了解详细功能
- 阅读 [架构说明.md](./架构说明.md) 了解代码结构
- 查看源代码了解实现细节

View File

@ -1,509 +0,0 @@
# VideoConcat 架构说明文档
## 架构概述
VideoConcat 采用经典的 MVVMModel-View-ViewModel架构模式结合服务层和工具层实现清晰的职责分离。
## 架构层次
```
┌─────────────────────────────────────┐
│ View Layer │ (Views/)
│ (XAML + Code-Behind) │
└──────────────┬──────────────────────┘
│ Data Binding
┌──────────────▼──────────────────────┐
│ ViewModel Layer │ (ViewModels/)
│ (业务逻辑 + 命令处理) │
└──────────────┬──────────────────────┘
┌──────────────▼──────────────────────┐
│ Model Layer │ (Models/)
│ (数据模型 + 业务状态) │
└──────────────┬──────────────────────┘
┌──────────────▼──────────────────────┐
│ Service Layer │ (Services/)
│ (核心业务逻辑处理) │
└──────────────┬──────────────────────┘
┌──────────────▼──────────────────────┐
│ Tools Layer │ (Common/Tools/)
│ (工具类 + 辅助功能) │
└─────────────────────────────────────┘
```
## 各层详细说明
### 1. View Layer视图层
**位置**: `Views/`
**职责**:
- 定义用户界面XAML
- 处理 UI 交互事件
- 数据绑定到 ViewModel
**主要文件**:
#### MainWindow.xaml / MainWindow.xaml.cs
- 主窗口,包含导航功能
- 通过 RadioButton 切换不同功能视图
- 动态加载 VideoWindow 或 ExtractWindow
**关键代码**:
```csharp
private void RadioButton_Checked(object sender, RoutedEventArgs e)
{
if (radioButton.Name == "extract")
{
var existingView = new ExtractWindow();
AddViewToColumn(mainGrid, existingView);
}
if (radioButton.Name == "video")
{
var existingView = new VideoWindow();
AddViewToColumn(mainGrid, existingView);
}
}
```
#### VideoWindow.xaml / VideoWindow.xaml.cs
- 视频拼接功能界面
- 显示文件夹列表、视频列表、拼接结果
#### ExtractWindow.xaml / ExtractWindow.xaml.cs
- 视频抽帧和元数据修改功能界面
- 显示视频文件列表和处理结果
#### LoginWindow.xaml / LoginWindow.xaml.cs
- 用户登录界面
### 2. ViewModel Layer视图模型层
**位置**: `ViewModels/`
**职责**:
- 处理用户交互逻辑
- 协调 Model 和 Service
- 实现命令模式
- 管理 UI 状态
**主要文件**:
#### VideoViewModel.cs
- 视频拼接功能的视图模型
- 实现文件夹选择、视频组合生成、拼接执行等逻辑
**核心流程**:
1. 用户选择文件夹 → `ListFolder()` 扫描视频文件
2. 生成视频组合 → `GenerateCombinations()` 或顺序组合
3. 转换视频格式 → 并发转换为 TS 格式
4. 拼接视频 → 使用 FFmpeg concat
5. 添加水印(可选)→ 图片叠加
**关键特性**:
- 使用 `SemaphoreSlim` 控制并发数量10个线程
- 使用 `Task.Run` 进行异步处理
- 通过 `Dispatcher.Invoke` 更新 UI 线程
#### ExtractWindowViewModel.cs
- 视频抽帧和元数据修改的视图模型
- 实现批量处理逻辑
#### MainWindowViewModel.cs
- 主窗口视图模型(当前较简单)
### 3. Model Layer模型层
**位置**: `Models/`
**职责**:
- 定义数据模型
- 实现 INotifyPropertyChanged 接口
- 管理业务状态
**主要文件**:
#### VideoModel.cs
- 视频拼接功能的数据模型
- 包含文件夹信息、视频列表、拼接结果等
**关键属性**:
- `FolderInfos`: 文件夹信息集合
- `ConcatVideos`: 拼接结果集合
- `IsJoinType1Selected` / `IsJoinType2Selected`: 拼接模式
- `CanStart`: 是否可以开始(基于数量和状态)
**状态管理**:
```csharp
public void SetCanStart()
{
if (Num > 0 && Num <= MaxNum && IsStart == false)
{
CanStart = true;
}
else
{
CanStart = false;
}
IsCanOperate = !IsStart;
}
```
#### ExtractWindowModel.cs
- 抽帧功能的数据模型
- 包含视频文件列表和处理状态
#### MainWindowModel.cs
- 主窗口数据模型
### 4. Service Layer服务层
**位置**: `Services/`
**职责**:
- 实现核心业务逻辑
- 封装视频处理操作
- 与 FFmpeg 交互
**主要文件**:
#### VideoService.cs
- 视频服务,提供视频转换和拼接功能
**核心方法**:
1. **ConvertVideos**: 视频格式转换
```csharp
public static string ConvertVideos(string videoPath)
{
// 1. 分析视频信息
var video = FFProbe.Analyse(videoPath);
// 2. 生成 MD5 缓存文件名
string _tempMd5Name = GetLargeFileMD5(videoPath);
var destinationPath = Path.Combine(Path.GetTempPath(), $"{_tempMd5Name}{FileExtension.Ts}");
// 3. 检查缓存
if (File.Exists(destinationPath))
return destinationPath;
// 4. 转换视频
FFMpeg.Convert(videoPath, destinationPath, VideoType.Ts);
return destinationPath;
}
```
2. **JoinVideos**: 视频拼接
- 使用 `FFMpegArguments.FromConcatInput()` 进行拼接
- 支持音频重新编码
- 可选添加图片水印
3. **GetLargeFileMD5**: 大文件 MD5 计算
- 使用流式读取,避免内存溢出
- 8KB 缓冲区优化
#### VideoProcess.cs
- 视频处理服务,提供抽帧、裁剪、元数据修改等功能
**核心方法**:
1. **RemoveFrameRandomeAsync**: 异步删除随机帧
- 处理 HEVC 编码(自动转换)
- 分割视频并重新合并
2. **ModifyByMetadata**: 修改元数据
- 添加唯一注释改变 MD5
- 使用流复制,不重新编码
3. **SubVideo / SubAudio**: 视频/音频裁剪
#### BaseService.cs
- 基础服务类(当前为空,可扩展)
### 5. Tools Layer工具层
**位置**: `Common/Tools/`
**职责**:
- 提供通用工具方法
- 封装第三方库调用
- 辅助功能实现
**主要文件**:
#### VideoCombine.cs
- 视频组合工具
- 提供组合生成和格式转换功能
**核心方法**:
```csharp
public static void GenerateCombinations(
List<List<string>> videoLists,
int index,
List<string> currentCombination,
List<List<string>> result)
{
// 递归生成所有可能的视频组合(笛卡尔积)
if (index == videoLists.Count)
{
result.Add([.. currentCombination]);
return;
}
foreach (string video in videoLists[index])
{
currentCombination.Add(video);
GenerateCombinations(videoLists, index + 1, currentCombination, result);
currentCombination.RemoveAt(currentCombination.Count - 1);
}
}
```
#### LogUtils.cs
- 日志工具类
- 基于 log4net 封装
- 提供 Info、Debug、Error、Warn、Fatal 等方法
#### HttpUtils.cs
- HTTP 请求工具
- 封装 HttpClient
- 提供 GetAsync 和 PostAsync 方法
#### Config.cs
- 配置管理工具
- 读取和更新 App.config 配置
### 6. API LayerAPI层
**位置**: `Common/Api/`
**职责**:
- 定义 API 接口
- 封装 HTTP 请求
**主要文件**:
#### SystemApi.cs
- 系统 API
- 提供登录接口
## 数据流
### 视频拼接流程
```
用户操作
VideoWindow (View)
↓ (命令绑定)
VideoViewModel
1. ListFolder() → 扫描文件夹
2. GenerateCombinations() → 生成组合
3. ConvertVideos() → 转换格式 (并发)
4. JoinVideos() → 拼接视频 (并发)
5. 更新 VideoModel.ConcatVideos
UI 自动更新 (数据绑定)
```
### 视频抽帧流程
```
用户操作
ExtractWindow (View)
↓ (命令绑定)
ExtractWindowViewModel
1. ListFolder() → 扫描视频文件
2. RemoveFrameRandomeAsync() → 抽帧处理 (并发)
3. VideoProcess.RemoveFrameRandomeAsync()
4. 输出到 out 文件夹
UI 更新状态
```
## 设计模式
### 1. MVVM 模式
- **Model**: 数据模型和业务状态
- **View**: XAML 界面
- **ViewModel**: 连接 View 和 Model
### 2. 命令模式
- 自定义 `Command` 类实现 `ICommand` 接口
- 将 UI 操作封装为命令
### 3. 观察者模式
- 使用 `INotifyPropertyChanged` 实现属性变更通知
- 使用 `ObservableCollection` 实现集合变更通知
### 4. 单例模式
- `LogUtils` 使用静态方法,类似单例
## 并发处理
### SemaphoreSlim 控制并发
```csharp
SemaphoreSlim semaphore = new(10); // 限制10个并发
foreach (var item in items)
{
await semaphore.WaitAsync();
var task = Task.Run(() =>
{
try
{
// 处理逻辑
}
finally
{
semaphore.Release();
}
});
tasks.Add(task);
}
await Task.WhenAll(tasks);
```
### 异步处理
- 使用 `async/await` 进行异步操作
- 使用 `Task.Run` 在后台线程执行耗时操作
- 使用 `Dispatcher.Invoke` 更新 UI 线程
## 缓存机制
### 视频转换缓存
基于 MD5 的文件名缓存:
```csharp
string _tempMd5Name = GetLargeFileMD5(videoPath);
var destinationPath = Path.Combine(Path.GetTempPath(), $"{_tempMd5Name}{FileExtension.Ts}");
if (File.Exists(destinationPath))
{
return destinationPath; // 直接返回缓存文件
}
```
**优势**:
- 避免重复转换相同文件
- 提高处理速度
- 减少 CPU 和磁盘 I/O
## 错误处理
### 异常捕获
```csharp
try
{
// 主要处理逻辑
FFMpeg.Convert(videoPath, destinationPath, VideoType.Ts);
}
catch (Exception ex)
{
// 备用方案
LogUtils.Info("视频转换失败!尝试另外一种转换");
try
{
FFMpegArguments
.FromFileInput(videoPath)
.OutputToFile(destinationPath, true, o => o
.WithVideoCodec("libx264")
// ... 其他参数
)
.ProcessSynchronously(true);
}
catch (Exception e)
{
LogUtils.Error($"{videoPath} 转换失败", ex);
LogUtils.Error($"{videoPath} 转换再次失败", e);
}
}
```
### 日志记录
- 所有错误都记录到日志文件
- 使用 log4net 进行日志管理
- 日志文件按日期命名
## 资源管理
### 临时文件清理
```csharp
public static void Cleanup(List<string> pathList)
{
foreach (var path in pathList)
{
if (File.Exists(path))
{
File.Delete(path);
}
}
}
```
### 文件流管理
使用 `using` 语句确保资源释放:
```csharp
using var md5 = MD5.Create();
using var stream = File.OpenRead(filePath);
// ... 处理逻辑
```
## 扩展性设计
### 1. 服务层扩展
- 可以添加新的 Service 类
- 继承 BaseService如需要
### 2. 工具类扩展
- 在 `Common/Tools/` 中添加新工具类
- 保持工具类的静态方法设计
### 3. API 扩展
- 在 `Common/Api/` 中添加新的 API 类
- 使用 HttpUtils 进行请求
### 4. 功能扩展
- 添加新的 View 和 ViewModel
- 在主窗口中添加导航入口
## 性能优化建议
1. **并发控制**: 已使用 SemaphoreSlim 限制并发
2. **缓存机制**: 已实现基于 MD5 的转换缓存
3. **异步处理**: 已使用 async/await 避免阻塞
4. **流式处理**: 大文件使用流式读取
**可进一步优化**:
- 添加进度报告机制
- 优化大文件 MD5 计算(可考虑分块并行)
- 添加视频预览功能(减少不必要的处理)
- 实现断点续传(大文件处理)
## 测试建议
1. **单元测试**: 为 Service 和 Tools 层添加单元测试
2. **集成测试**: 测试完整的视频处理流程
3. **性能测试**: 测试并发处理和大量文件处理
4. **异常测试**: 测试各种异常情况(文件不存在、格式不支持等)

View File

@ -1,402 +0,0 @@
# VideoConcat 项目文档
## 项目概述
VideoConcat 是一个基于 WPF 开发的视频处理应用程序,主要用于视频拼接、视频抽帧和视频元数据修改等功能。项目采用 MVVM 架构模式,使用 FFMpegCore 库进行视频处理操作。
## 技术栈
- **框架**: .NET 8.0
- **UI框架**: WPF (Windows Presentation Foundation)
- **视频处理**: FFMpegCore 5.2.0
- **日志**: log4net 3.1.0
- **UI组件库**:
- MaterialDesignThemes.Wpf
- MahApps.Metro.IconPacks
- LiveCharts.Wpf (图表库)
- **依赖注入**: Microsoft.Extensions.DependencyInjection
- **JSON处理**: Newtonsoft.Json 13.0.3
## 项目结构
```
VideoConcat/
├── Common/ # 公共模块
│ ├── Api/ # API接口
│ │ ├── Base/ # 基础API
│ │ │ ├── SystemApi.cs # 系统API登录等
│ │ │ └── LoginResponse.cs
│ │ └── Common/ # 通用API
│ └── Tools/ # 工具类
│ ├── Config.cs # 配置管理
│ ├── HttpUtils.cs # HTTP请求封装
│ ├── LogUtils.cs # 日志工具
│ └── VideoCombine.cs # 视频组合工具
├── Conversions/ # 转换器
│ └── EnumToBooleanConverter.cs
├── Helpers/ # 辅助类
│ └── PasswordBoxHelper.cs
├── Models/ # 数据模型
│ ├── ExtractWindowModel.cs # 抽帧窗口模型
│ ├── MainWindowModel.cs # 主窗口模型
│ └── VideoModel.cs # 视频模型
├── Services/ # 服务层
│ ├── BaseService.cs # 基础服务
│ └── Video/ # 视频服务
│ ├── VideoProcess.cs # 视频处理服务
│ └── VideoService.cs # 视频服务
├── ViewModels/ # 视图模型
│ ├── ExtractWindowViewModel.cs
│ ├── MainWindowViewModel.cs
│ └── VideoViewModel.cs
├── Views/ # 视图
│ ├── ExtractWindow.xaml # 抽帧窗口
│ ├── LoginWindow.xaml # 登录窗口
│ ├── MainWindow.xaml # 主窗口
│ └── VideoWindow.xaml # 视频窗口
├── App.xaml # 应用程序入口
├── VideoConcat.csproj # 项目文件
└── README.md # 项目说明
```
## 主要功能
### 1. 视频拼接功能
支持两种拼接模式:
#### 模式一组合拼接IsJoinType1Selected
- 从多个文件夹中选择视频文件
- 生成所有可能的视频组合
- 随机选择指定数量的组合进行拼接
- 支持添加审核图片水印
#### 模式二顺序拼接IsJoinType2Selected
- 按照文件夹顺序,从每个文件夹中选择对应位置的视频
- 要求所有文件夹中的视频数量相同
- 按索引顺序拼接视频
**拼接流程**
1. 选择包含视频文件夹的目录
2. 系统自动扫描各文件夹中的 MP4 文件
3. 根据选择的模式生成视频组合
4. 将视频转换为 TS 格式(临时文件,基于 MD5 缓存)
5. 使用 FFmpeg 进行视频拼接
6. 可选:添加审核图片水印
7. 输出到 `output` 文件夹
### 2. 视频抽帧功能
- 随机删除视频中的某一帧
- 支持 HEVC 编码格式(自动转换为 H.264
- 保持视频和音频同步
- 批量处理视频文件
**处理流程**
1. 分析视频信息(时长、帧率等)
2. 随机选择要删除的帧(避开开头和结尾)
3. 将视频分割为两部分(删除帧前后)
4. 重新合并视频
5. 输出到 `out` 文件夹
### 3. 视频元数据修改
- 通过修改视频元数据中的注释信息改变文件 MD5
- 使用流复制模式,不重新编码视频
- 批量处理视频文件
### 4. 用户登录
- 支持用户登录验证
- 记录登录设备信息机器名、用户名、IP地址
- 与后端 API 交互进行身份验证
## 核心模块说明
### VideoService视频服务
**主要方法**
- `ConvertVideos(string videoPath)`: 将视频文件转换为 TS 格式
- 使用 MD5 作为临时文件名,实现缓存机制
- 支持多种编码格式转换
- 失败时自动尝试备用转换方案
- `GetLargeFileMD5(string filePath)`: 计算大文件的 MD5 值
- 使用流式读取,支持大文件处理
- 8KB 缓冲区优化内存使用
- `JoinVideos(List<string> combination)`: 拼接视频列表
- 使用 FFmpeg concat 功能
- 支持音频重新编码AAC44.1kHz
- 可选添加图片水印
- `Cleanup(List<string> pathList)`: 清理临时文件
### VideoProcess视频处理
**主要方法**
- `RemoveFrameRandomeAsync(string inputPath, string outputPath)`: 异步删除随机帧
- 自动处理 HEVC 编码
- 保持音视频同步
- `ModifyByMetadata(string inputPath, string outputPath, string comment)`: 修改视频元数据
- 添加唯一注释信息
- 使用流复制,不重新编码
- `SubVideo(string inputPath, string outputPath, double startSec, double endSec)`: 视频裁剪
- `SubAudio(string inputPath, string outputPath, double startSec, double endSec)`: 音频裁剪
- `JoinVideo(string outPutPath, string[] videoParts)`: 视频合并
### VideoCombine视频组合工具
**主要方法**
- `GenerateCombinations(List<List<string>> videoLists, int index, List<string> currentCombination, List<List<string>> result)`: 生成所有视频组合
- 递归算法生成笛卡尔积
- 支持多文件夹视频组合
- `ConvertVideos(string videoPath)`: 视频格式转换(与 VideoService 类似)
### LogUtils日志工具
基于 log4net 的日志封装,支持:
- Info: 信息日志
- Debug: 调试日志
- Error: 错误日志
- Warn: 警告日志
- Fatal: 致命错误日志
日志文件保存在 `bin/Debug/net8.0-windows/Log/` 目录下。
### HttpUtilsHTTP工具
封装了 HTTP 请求功能:
- `GetAsync<T>(string url)`: GET 请求
- `PostAsync<T>(string url, object data)`: POST 请求
- 默认 BaseAddress: `https://admin.xiangbing.vip`
- 自动 JSON 序列化/反序列化
## 数据模型
### VideoModel视频模型
**主要属性**
- `FolderPath`: 视频文件夹路径
- `FolderInfos`: 文件夹信息集合(包含视频文件列表)
- `ConcatVideos`: 拼接完成的视频列表
- `Num`: 需要拼接的视频数量
- `MaxNum`: 最大可拼接数量
- `AuditImagePath`: 审核图片路径
- `IsJoinType1Selected`: 组合拼接模式
- `IsJoinType2Selected`: 顺序拼接模式
- `CanStart`: 是否可以开始拼接
- `IsStart`: 是否正在处理
**FolderInfo 结构**
```csharp
public struct FolderInfo
{
public DirectoryInfo DirectoryInfo;
public int Num; // 视频文件数量
public List<string> VideoPaths; // 视频文件路径列表
}
```
**ConcatVideo 类**
```csharp
public class ConcatVideo
{
public int Index; // 索引
public string FileName; // 文件名
public string Size; // 文件大小
public int Seconds; // 时长(秒)
public string Status; // 状态
public string Progress; // 进度
}
```
### ExtractWindowModel抽帧窗口模型
**主要属性**
- `FolderPath`: 视频文件夹路径
- `videos`: 视频文件数组
- `CanExtractFrame`: 是否可以抽帧
- `CanModify`: 是否可以修改元数据
- `IsStart`: 是否正在处理
## 配置说明
### App.config
应用程序配置文件,用于存储应用设置。
### log4net.config
日志配置文件,定义日志输出格式和存储位置。
### app.manifest
应用程序清单文件,定义应用程序的权限和特性。
## 依赖项说明
| 包名 | 版本 | 说明 |
|------|------|------|
| FFMpegCore | 5.2.0 | FFmpeg 的 .NET 封装,用于视频处理 |
| LiveCharts | 0.9.7 | 图表库,用于数据可视化 |
| LiveCharts.Wpf | 0.9.7 | WPF 图表组件 |
| log4net | 3.1.0 | 日志框架 |
| MahApps.Metro.IconPacks | 6.0.0 | Metro 风格图标包 |
| MaterialDesignThemes.Wpf | - | Material Design 主题 |
| Microsoft.Extensions.Logging | 9.0.8 | 日志扩展 |
| Newtonsoft.Json | 13.0.3 | JSON 序列化库 |
| WPFDevelopers | 0.0.0.1 | WPF 开发工具库 |
## 使用说明
### 环境要求
- Windows 操作系统
- .NET 8.0 Runtime
- FFmpeg 可执行文件(已包含在 bin 目录中)
### 运行步骤
1. **编译项目**
```bash
dotnet build
```
2. **运行程序**
```bash
dotnet run
```
或直接运行 `bin/Debug/net8.0-windows/VideoConcat.exe`
3. **使用视频拼接功能**
- 打开程序,选择"视频"标签
- 点击"选择文件夹",选择包含视频文件夹的目录
- 选择拼接模式(组合拼接或顺序拼接)
- 输入需要拼接的视频数量
- (可选)选择审核图片
- 点击"开始拼接"按钮
4. **使用视频抽帧功能**
- 选择"抽帧"标签
- 点击"选择文件夹",选择包含视频文件的目录
- 点击"开始抽帧"按钮
5. **使用元数据修改功能**
- 在"抽帧"标签中
- 选择视频文件夹
- 点击"开始修改"按钮
### 输出位置
- **拼接视频**: `{选择的文件夹}/output/`
- **抽帧视频**: `{选择的文件夹}/out/`
- **修改元数据视频**: `{选择的文件夹}/out/modify{原文件名}`
### 临时文件
- 视频转换后的 TS 文件存储在系统临时目录
- 文件名基于源文件的 MD5 值,实现缓存机制
- 处理完成后会自动清理临时文件
## 性能优化
1. **并发处理**: 使用 `SemaphoreSlim` 限制并发数量默认10个线程
2. **文件缓存**: 基于 MD5 的临时文件缓存,避免重复转换
3. **流式处理**: 大文件 MD5 计算使用流式读取
4. **异步处理**: 视频处理操作使用异步方法,避免 UI 阻塞
## 注意事项
1. **视频格式**: 目前主要支持 MP4 格式
2. **文件路径**: 确保视频文件路径不包含特殊字符
3. **磁盘空间**: 视频处理需要足够的临时存储空间
4. **处理时间**: 视频处理时间取决于视频大小和数量
5. **HEVC 编码**: 抽帧功能会自动将 HEVC 编码转换为 H.264
## 日志说明
日志文件保存在 `bin/Debug/net8.0-windows/Log/` 目录下,按日期命名(如 `log20251019.log`)。
日志内容包括:
- 视频处理进度
- 错误信息
- 操作记录
- 性能统计
## API 接口
### 登录接口
**端点**: `/api/base/login`
**请求参数**:
```json
{
"Username": "用户名",
"Password": "密码",
"Platform": "pc",
"PcName": "机器名",
"PcUserName": "用户名",
"Ips": "IP地址"
}
```
**响应格式**:
```json
{
"Code": 200,
"Msg": "消息",
"Data": {
// 用户信息
}
}
```
## 开发说明
### MVVM 架构
项目采用 MVVMModel-View-ViewModel架构模式
- **Model**: 数据模型和业务逻辑Models 目录)
- **View**: 用户界面Views 目录XAML 文件)
- **ViewModel**: 视图模型,连接 View 和 ModelViewModels 目录)
### 命令模式
使用自定义 `Command` 类实现命令模式,绑定到 UI 控件的命令属性。
### 数据绑定
使用 WPF 数据绑定机制,实现 UI 与数据的双向绑定。
## 已知问题
1. 抽帧功能在某些情况下可能返回 false代码中 `RemoveFrameRandomeAsync` 方法最后返回 false
2. 顺序拼接模式要求所有文件夹视频数量相同,但错误提示信息不够明确
## 未来改进方向
1. 支持更多视频格式
2. 添加视频预览功能
3. 优化错误处理和用户提示
4. 添加进度条显示
5. 支持视频质量设置
6. 添加批量重命名功能
7. 支持视频裁剪功能(时间范围选择)
## 许可证
(待补充)
## 联系方式
(待补充)

6
wails/.gitignore vendored
View File

@ -1,6 +0,0 @@
.task
bin
frontend/dist
frontend/node_modules
build/linux/appimage/build
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe

View File

@ -1,59 +0,0 @@
# Welcome to Your New Wails3 Project!
Congratulations on generating your Wails3 application! This README will guide you through the next steps to get your project up and running.
## Getting Started
1. Navigate to your project directory in the terminal.
2. To run your application in development mode, use the following command:
```
wails3 dev
```
This will start your application and enable hot-reloading for both frontend and backend changes.
3. To build your application for production, use:
```
wails3 build
```
This will create a production-ready executable in the `build` directory.
## Exploring Wails3 Features
Now that you have your project set up, it's time to explore the features that Wails3 offers:
1. **Check out the examples**: The best way to learn is by example. Visit the `examples` directory in the `v3/examples` directory to see various sample applications.
2. **Run an example**: To run any of the examples, navigate to the example's directory and use:
```
go run .
```
Note: Some examples may be under development during the alpha phase.
3. **Explore the documentation**: Visit the [Wails3 documentation](https://v3.wails.io/) for in-depth guides and API references.
4. **Join the community**: Have questions or want to share your progress? Join the [Wails Discord](https://discord.gg/JDdSxwjhGf) or visit the [Wails discussions on GitHub](https://github.com/wailsapp/wails/discussions).
## Project Structure
Take a moment to familiarize yourself with your project structure:
- `frontend/`: Contains your frontend code (HTML, CSS, JavaScript/TypeScript)
- `main.go`: The entry point of your Go backend
- `app.go`: Define your application structure and methods here
- `wails.json`: Configuration file for your Wails project
## Next Steps
1. Modify the frontend in the `frontend/` directory to create your desired UI.
2. Add backend functionality in `main.go`.
3. Use `wails3 dev` to see your changes in real-time.
4. When ready, build your application with `wails3 build`.
Happy coding with Wails3! If you encounter any issues or have questions, don't hesitate to consult the documentation or reach out to the Wails community.

View File

@ -1,40 +0,0 @@
version: '3'
includes:
common: ./build/Taskfile.yml
windows: ./build/windows/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml
linux: ./build/linux/Taskfile.yml
ios: ./build/ios/Taskfile.yml
android: ./build/android/Taskfile.yml
vars:
APP_NAME: "VideoConcat"
BIN_DIR: "bin"
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
tasks:
build:
summary: Builds the application
cmds:
- task: "{{OS}}:build"
package:
summary: Packages a production build of the application
cmds:
- task: "{{OS}}:package"
run:
summary: Runs the application
cmds:
- task: "{{OS}}:run"
dev:
summary: Runs the application in development mode
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
setup:docker:
summary: Builds Docker image for cross-compilation (~800MB download)
cmds:
- task: common:setup:docker

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,51 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>视频拼接工具</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(to bottom, #fefefe, #ededef);
overflow: hidden;
}
#app {
width: 100vw;
height: 100vh;
}
</style>
<script type="module" crossorigin src="/assets/index-B-ziti-G.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-dkqEFoMI.css">
</head>
<body>
<div id="app"></div>
<script>
// 添加调试信息,检查页面是否加载
console.log('HTML 页面已加载');
console.log('等待 JavaScript 模块加载...');
// 检查资源是否加载
window.addEventListener('error', function(e) {
console.error('资源加载错误:', e.filename, e.message);
}, true);
// 延迟检查 Vue 应用是否挂载
setTimeout(function() {
const app = document.getElementById('app');
if (app && app.innerHTML.trim() === '') {
console.error('Vue 应用未正确挂载到 #app 元素');
} else {
console.log('Vue 应用可能已挂载');
}
}, 2000);
</script>
</body>
</html>

View File

@ -1,193 +0,0 @@
version: '3'
tasks:
go:mod:tidy:
summary: Runs `go mod tidy`
internal: true
cmds:
- go mod tidy
install:frontend:deps:
summary: Install frontend dependencies
dir: frontend
sources:
- package.json
- package-lock.json
generates:
- node_modules
preconditions:
- sh: npm version
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
cmds:
- npm install
build:frontend:
label: build:frontend (DEV={{.DEV}})
summary: Build the frontend project
dir: frontend
sources:
- "**/*"
generates:
- dist/**/*
deps:
- task: install:frontend:deps
- task: generate:bindings
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
cmds:
- npm run {{.BUILD_COMMAND}} -q
env:
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
vars:
BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
frontend:vendor:puppertino:
summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling
sources:
- frontend/public/puppertino/puppertino.css
generates:
- frontend/public/puppertino/puppertino.css
cmds:
- |
set -euo pipefail
mkdir -p frontend/public/puppertino
# If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error.
if [ ! -f frontend/public/puppertino/puppertino.css ]; then
echo "No bundled Puppertino found. Attempting to fetch from GitHub..."
if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then
curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true
echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css"
else
echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it."
fi
else
echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css"
fi
# Ensure index.html includes Puppertino CSS and button classes
INDEX_HTML=frontend/index.html
if [ -f "$INDEX_HTML" ]; then
if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then
# Insert Puppertino link tag after style.css link
awk '
/href="\/style.css"\/?/ && !x { print; print " <link rel=\"stylesheet\" href=\"/puppertino/puppertino.css\"/>"; x=1; next }1
' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML"
fi
# Replace default .btn with Puppertino primary button classes if present
sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true
fi
generate:bindings:
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
summary: Generates bindings for the frontend
deps:
- task: go:mod:tidy
sources:
- "**/*.[jt]s"
- exclude: frontend/**/*
- frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output
- "**/*.go"
- go.mod
- go.sum
generates:
- frontend/bindings/**/*
cmds:
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
generate:icons:
summary: Generates Windows `.ico` and Mac `.icns` files from an image
dir: build
sources:
- "appicon.png"
generates:
- "darwin/icons.icns"
- "windows/icon.ico"
cmds:
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico
dev:frontend:
summary: Runs the frontend in development mode
dir: frontend
deps:
- task: install:frontend:deps
cmds:
- npm run dev -- --port {{.VITE_PORT}} --strictPort
update:build-assets:
summary: Updates the build assets
dir: build
cmds:
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
setup:docker:
summary: Builds Docker image for cross-compilation (~800MB download)
desc: |
Builds the Docker image needed for cross-compiling to any platform.
Run this once to enable cross-platform builds from any OS.
cmds:
- docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required. Please install Docker first."
ios:device:list:
summary: Lists connected iOS devices (UDIDs)
cmds:
- xcrun xcdevice list
ios:run:device:
summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl)
vars:
PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/<YourProject>.xcodeproj
SCHEME: '{{.SCHEME}}' # e.g., ios.dev
CONFIG: '{{.CONFIG | default "Debug"}}'
DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}'
UDID: '{{.UDID}}' # from `task ios:device:list`
BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev
TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing
preconditions:
- sh: xcrun -f xcodebuild
msg: "xcodebuild not found. Please install Xcode."
- sh: xcrun -f devicectl
msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)."
- sh: test -n '{{.PROJECT}}'
msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)."
- sh: test -n '{{.SCHEME}}'
msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)."
- sh: test -n '{{.UDID}}'
msg: "Set UDID to your device UDID (see: task ios:device:list)."
- sh: test -n '{{.BUNDLE_ID}}'
msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)."
cmds:
- |
set -euo pipefail
echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}"
XCB_ARGS=(
-project "{{.PROJECT}}"
-scheme "{{.SCHEME}}"
-configuration "{{.CONFIG}}"
-destination "id={{.UDID}}"
-derivedDataPath "{{.DERIVED}}"
-allowProvisioningUpdates
-allowProvisioningDeviceRegistration
)
# Optionally inject signing identifiers if provided
if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi
if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi
xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true
# If xcpretty isn't installed, run without it
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
xcodebuild "${XCB_ARGS[@]}" build
fi
# Find built .app
APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1)
if [ -z "$APP_PATH" ]; then
echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2
exit 1
fi
echo "Installing: $APP_PATH"
xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH"
echo "Launching: {{.BUNDLE_ID}}"
xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}"

View File

@ -1,237 +0,0 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
APP_ID: '{{.APP_ID | default "com.wails.app"}}'
MIN_SDK: '21'
TARGET_SDK: '34'
NDK_VERSION: 'r26d'
tasks:
install:deps:
summary: Check and install Android development dependencies
cmds:
- go run build/android/scripts/deps/install_deps.go
env:
TASK_FORCE_YES: '{{if .YES}}true{{else}}false{{end}}'
prompt: This will check and install Android development dependencies. Continue?
build:
summary: Creates a build of the application for Android
deps:
- task: common:go:mod:tidy
- task: generate:android:bindings
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
PRODUCTION:
ref: .PRODUCTION
- task: common:generate:icons
cmds:
- echo "Building Android app {{.APP_NAME}}..."
- task: compile:go:shared
vars:
ARCH: '{{.ARCH | default "arm64"}}'
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}'
env:
PRODUCTION: '{{.PRODUCTION | default "false"}}'
compile:go:shared:
summary: Compile Go code to shared library (.so)
cmds:
- |
NDK_ROOT="${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/{{.NDK_VERSION}}}"
if [ ! -d "$NDK_ROOT" ]; then
echo "Error: Android NDK not found at $NDK_ROOT"
echo "Please set ANDROID_NDK_HOME or install NDK {{.NDK_VERSION}} via Android Studio"
exit 1
fi
# Determine toolchain based on host OS
case "$(uname -s)" in
Darwin) HOST_TAG="darwin-x86_64" ;;
Linux) HOST_TAG="linux-x86_64" ;;
*) echo "Unsupported host OS"; exit 1 ;;
esac
TOOLCHAIN="$NDK_ROOT/toolchains/llvm/prebuilt/$HOST_TAG"
# Set compiler based on architecture
case "{{.ARCH}}" in
arm64)
export CC="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang"
export CXX="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang++"
export GOARCH=arm64
JNI_DIR="arm64-v8a"
;;
amd64|x86_64)
export CC="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang"
export CXX="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang++"
export GOARCH=amd64
JNI_DIR="x86_64"
;;
*)
echo "Unsupported architecture: {{.ARCH}}"
exit 1
;;
esac
export CGO_ENABLED=1
export GOOS=android
mkdir -p {{.BIN_DIR}}
mkdir -p build/android/app/src/main/jniLibs/$JNI_DIR
go build -buildmode=c-shared {{.BUILD_FLAGS}} \
-o build/android/app/src/main/jniLibs/$JNI_DIR/libwails.so
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}'
compile:go:all-archs:
summary: Compile Go code for all Android architectures (fat APK)
cmds:
- task: compile:go:shared
vars:
ARCH: arm64
- task: compile:go:shared
vars:
ARCH: amd64
package:
summary: Packages a production build of the application into an APK
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: assemble:apk
package:fat:
summary: Packages a production build for all architectures (fat APK)
cmds:
- task: compile:go:all-archs
- task: assemble:apk
assemble:apk:
summary: Assembles the APK using Gradle
cmds:
- |
cd build/android
./gradlew assembleDebug
cp app/build/outputs/apk/debug/app-debug.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}.apk"
echo "APK created: {{.BIN_DIR}}/{{.APP_NAME}}.apk"
assemble:apk:release:
summary: Assembles a release APK using Gradle
cmds:
- |
cd build/android
./gradlew assembleRelease
cp app/build/outputs/apk/release/app-release-unsigned.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}-release.apk"
echo "Release APK created: {{.BIN_DIR}}/{{.APP_NAME}}-release.apk"
generate:android:bindings:
internal: true
summary: Generates bindings for Android
sources:
- "**/*.go"
- go.mod
- go.sum
generates:
- frontend/bindings/**/*
cmds:
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
env:
GOOS: android
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default "arm64"}}'
ensure-emulator:
internal: true
summary: Ensure Android Emulator is running
silent: true
cmds:
- |
# Check if an emulator is already running
if adb devices | grep -q "emulator"; then
echo "Emulator already running"
exit 0
fi
# Get first available AVD
AVD_NAME=$(emulator -list-avds | head -1)
if [ -z "$AVD_NAME" ]; then
echo "No Android Virtual Devices found."
echo "Create one using: Android Studio > Tools > Device Manager"
exit 1
fi
echo "Starting emulator: $AVD_NAME"
emulator -avd "$AVD_NAME" -no-snapshot-load &
# Wait for emulator to boot (max 60 seconds)
echo "Waiting for emulator to boot..."
adb wait-for-device
for i in {1..60}; do
BOOT_COMPLETED=$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
if [ "$BOOT_COMPLETED" = "1" ]; then
echo "Emulator booted successfully"
exit 0
fi
sleep 1
done
echo "Emulator boot timeout"
exit 1
preconditions:
- sh: command -v adb
msg: "adb not found. Please install Android SDK and add platform-tools to PATH"
- sh: command -v emulator
msg: "emulator not found. Please install Android SDK and add emulator to PATH"
deploy-emulator:
summary: Deploy to Android Emulator
deps: [package]
cmds:
- adb uninstall {{.APP_ID}} 2>/dev/null || true
- adb install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
- adb shell am start -n {{.APP_ID}}/.MainActivity
run:
summary: Run the application in Android Emulator
deps:
- task: ensure-emulator
- task: build
vars:
ARCH: x86_64
cmds:
- task: assemble:apk
- adb uninstall {{.APP_ID}} 2>/dev/null || true
- adb install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
- adb shell am start -n {{.APP_ID}}/.MainActivity
logs:
summary: Stream Android logcat filtered to this app
cmds:
- adb logcat -v time | grep -E "(Wails|{{.APP_NAME}})"
logs:all:
summary: Stream all Android logcat (verbose)
cmds:
- adb logcat -v time
clean:
summary: Clean build artifacts
cmds:
- rm -rf {{.BIN_DIR}}
- rm -rf build/android/app/build
- rm -rf build/android/app/src/main/jniLibs/*/libwails.so
- rm -rf build/android/.gradle

View File

@ -1,63 +0,0 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.wails.app'
compileSdk 34
buildFeatures {
buildConfig = true
}
defaultConfig {
applicationId "com.wails.app"
minSdk 21
targetSdk 34
versionCode 1
versionName "1.0"
// Configure supported ABIs
ndk {
abiFilters 'arm64-v8a', 'x86_64'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
debuggable true
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
// Source sets configuration
sourceSets {
main {
// JNI libraries are in jniLibs folder
jniLibs.srcDirs = ['src/main/jniLibs']
// Assets for the WebView
assets.srcDirs = ['src/main/assets']
}
}
// Packaging options
packagingOptions {
// Don't strip Go symbols in debug builds
doNotStrip '*/arm64-v8a/libwails.so'
doNotStrip '*/x86_64/libwails.so'
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.webkit:webkit:1.9.0'
implementation 'com.google.android.material:material:1.11.0'
}

View File

@ -1,12 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Keep Wails bridge classes
-keep class com.wails.app.WailsBridge { *; }
-keep class com.wails.app.WailsJSBridge { *; }

View File

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Internet permission for WebView -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.WailsApp"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -1,198 +0,0 @@
package com.wails.app;
import android.annotation.SuppressLint;
import android.os.Bundle;
import android.util.Log;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.webkit.WebViewAssetLoader;
import com.wails.app.BuildConfig;
/**
* MainActivity hosts the WebView and manages the Wails application lifecycle.
* It uses WebViewAssetLoader to serve assets from the Go library without
* requiring a network server.
*/
public class MainActivity extends AppCompatActivity {
private static final String TAG = "WailsActivity";
private static final String WAILS_SCHEME = "https";
private static final String WAILS_HOST = "wails.localhost";
private WebView webView;
private WailsBridge bridge;
private WebViewAssetLoader assetLoader;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Initialize the native Go library
bridge = new WailsBridge(this);
bridge.initialize();
// Set up WebView
setupWebView();
// Load the application
loadApplication();
}
@SuppressLint("SetJavaScriptEnabled")
private void setupWebView() {
webView = findViewById(R.id.webview);
// Configure WebView settings
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setDatabaseEnabled(true);
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
settings.setMediaPlaybackRequiresUserGesture(false);
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
// Enable debugging in debug builds
if (BuildConfig.DEBUG) {
WebView.setWebContentsDebuggingEnabled(true);
}
// Set up asset loader for serving local assets
assetLoader = new WebViewAssetLoader.Builder()
.setDomain(WAILS_HOST)
.addPathHandler("/", new WailsPathHandler(bridge))
.build();
// Set up WebView client to intercept requests
webView.setWebViewClient(new WebViewClient() {
@Nullable
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
Log.d(TAG, "Intercepting request: " + url);
// Handle wails.localhost requests
if (request.getUrl().getHost() != null &&
request.getUrl().getHost().equals(WAILS_HOST)) {
// For wails API calls (runtime, capabilities, etc.), we need to pass the full URL
// including query string because WebViewAssetLoader.PathHandler strips query params
String path = request.getUrl().getPath();
if (path != null && path.startsWith("/wails/")) {
// Get full path with query string for runtime calls
String fullPath = path;
String query = request.getUrl().getQuery();
if (query != null && !query.isEmpty()) {
fullPath = path + "?" + query;
}
Log.d(TAG, "Wails API call detected, full path: " + fullPath);
// Call bridge directly with full path
byte[] data = bridge.serveAsset(fullPath, request.getMethod(), "{}");
if (data != null && data.length > 0) {
java.io.InputStream inputStream = new java.io.ByteArrayInputStream(data);
java.util.Map<String, String> headers = new java.util.HashMap<>();
headers.put("Access-Control-Allow-Origin", "*");
headers.put("Cache-Control", "no-cache");
headers.put("Content-Type", "application/json");
return new WebResourceResponse(
"application/json",
"UTF-8",
200,
"OK",
headers,
inputStream
);
}
// Return error response if data is null
return new WebResourceResponse(
"application/json",
"UTF-8",
500,
"Internal Error",
new java.util.HashMap<>(),
new java.io.ByteArrayInputStream("{}".getBytes())
);
}
// For regular assets, use the asset loader
return assetLoader.shouldInterceptRequest(request.getUrl());
}
return super.shouldInterceptRequest(view, request);
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
Log.d(TAG, "Page loaded: " + url);
// Inject Wails runtime
bridge.injectRuntime(webView, url);
}
});
// Add JavaScript interface for Go communication
webView.addJavascriptInterface(new WailsJSBridge(bridge, webView), "wails");
}
private void loadApplication() {
// Load the main page from the asset server
String url = WAILS_SCHEME + "://" + WAILS_HOST + "/";
Log.d(TAG, "Loading URL: " + url);
webView.loadUrl(url);
}
/**
* Execute JavaScript in the WebView from the Go side
*/
public void executeJavaScript(final String js) {
runOnUiThread(() -> {
if (webView != null) {
webView.evaluateJavascript(js, null);
}
});
}
@Override
protected void onResume() {
super.onResume();
if (bridge != null) {
bridge.onResume();
}
}
@Override
protected void onPause() {
super.onPause();
if (bridge != null) {
bridge.onPause();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if (bridge != null) {
bridge.shutdown();
}
if (webView != null) {
webView.destroy();
}
}
@Override
public void onBackPressed() {
if (webView != null && webView.canGoBack()) {
webView.goBack();
} else {
super.onBackPressed();
}
}
}

View File

@ -1,214 +0,0 @@
package com.wails.app;
import android.content.Context;
import android.util.Log;
import android.webkit.WebView;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* WailsBridge manages the connection between the Java/Android side and the Go native library.
* It handles:
* - Loading and initializing the native Go library
* - Serving asset requests from Go
* - Passing messages between JavaScript and Go
* - Managing callbacks for async operations
*/
public class WailsBridge {
private static final String TAG = "WailsBridge";
static {
// Load the native Go library
System.loadLibrary("wails");
}
private final Context context;
private final AtomicInteger callbackIdGenerator = new AtomicInteger(0);
private final ConcurrentHashMap<Integer, AssetCallback> pendingAssetCallbacks = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Integer, MessageCallback> pendingMessageCallbacks = new ConcurrentHashMap<>();
private WebView webView;
private volatile boolean initialized = false;
// Native methods - implemented in Go
private static native void nativeInit(WailsBridge bridge);
private static native void nativeShutdown();
private static native void nativeOnResume();
private static native void nativeOnPause();
private static native void nativeOnPageFinished(String url);
private static native byte[] nativeServeAsset(String path, String method, String headers);
private static native String nativeHandleMessage(String message);
private static native String nativeGetAssetMimeType(String path);
public WailsBridge(Context context) {
this.context = context;
}
/**
* Initialize the native Go library
*/
public void initialize() {
if (initialized) {
return;
}
Log.i(TAG, "Initializing Wails bridge...");
try {
nativeInit(this);
initialized = true;
Log.i(TAG, "Wails bridge initialized successfully");
} catch (Exception e) {
Log.e(TAG, "Failed to initialize Wails bridge", e);
}
}
/**
* Shutdown the native Go library
*/
public void shutdown() {
if (!initialized) {
return;
}
Log.i(TAG, "Shutting down Wails bridge...");
try {
nativeShutdown();
initialized = false;
} catch (Exception e) {
Log.e(TAG, "Error during shutdown", e);
}
}
/**
* Called when the activity resumes
*/
public void onResume() {
if (initialized) {
nativeOnResume();
}
}
/**
* Called when the activity pauses
*/
public void onPause() {
if (initialized) {
nativeOnPause();
}
}
/**
* Serve an asset from the Go asset server
* @param path The URL path requested
* @param method The HTTP method
* @param headers The request headers as JSON
* @return The asset data, or null if not found
*/
public byte[] serveAsset(String path, String method, String headers) {
if (!initialized) {
Log.w(TAG, "Bridge not initialized, cannot serve asset: " + path);
return null;
}
Log.d(TAG, "Serving asset: " + path);
try {
return nativeServeAsset(path, method, headers);
} catch (Exception e) {
Log.e(TAG, "Error serving asset: " + path, e);
return null;
}
}
/**
* Get the MIME type for an asset
* @param path The asset path
* @return The MIME type string
*/
public String getAssetMimeType(String path) {
if (!initialized) {
return "application/octet-stream";
}
try {
String mimeType = nativeGetAssetMimeType(path);
return mimeType != null ? mimeType : "application/octet-stream";
} catch (Exception e) {
Log.e(TAG, "Error getting MIME type for: " + path, e);
return "application/octet-stream";
}
}
/**
* Handle a message from JavaScript
* @param message The message from JavaScript (JSON)
* @return The response to send back to JavaScript (JSON)
*/
public String handleMessage(String message) {
if (!initialized) {
Log.w(TAG, "Bridge not initialized, cannot handle message");
return "{\"error\":\"Bridge not initialized\"}";
}
Log.d(TAG, "Handling message from JS: " + message);
try {
return nativeHandleMessage(message);
} catch (Exception e) {
Log.e(TAG, "Error handling message", e);
return "{\"error\":\"" + e.getMessage() + "\"}";
}
}
/**
* Inject the Wails runtime JavaScript into the WebView.
* Called when the page finishes loading.
* @param webView The WebView to inject into
* @param url The URL that finished loading
*/
public void injectRuntime(WebView webView, String url) {
this.webView = webView;
// Notify Go side that page has finished loading so it can inject the runtime
Log.d(TAG, "Page finished loading: " + url + ", notifying Go side");
if (initialized) {
nativeOnPageFinished(url);
}
}
/**
* Execute JavaScript in the WebView (called from Go side)
* @param js The JavaScript code to execute
*/
public void executeJavaScript(String js) {
if (webView != null) {
webView.post(() -> webView.evaluateJavascript(js, null));
}
}
/**
* Called from Go when an event needs to be emitted to JavaScript
* @param eventName The event name
* @param eventData The event data (JSON)
*/
public void emitEvent(String eventName, String eventData) {
String js = String.format("window.wails && window.wails._emit('%s', %s);",
escapeJsString(eventName), eventData);
executeJavaScript(js);
}
private String escapeJsString(String str) {
return str.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r");
}
// Callback interfaces
public interface AssetCallback {
void onAssetReady(byte[] data, String mimeType);
void onAssetError(String error);
}
public interface MessageCallback {
void onResponse(String response);
void onError(String error);
}
}

View File

@ -1,142 +0,0 @@
package com.wails.app;
import android.util.Log;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import com.wails.app.BuildConfig;
/**
* WailsJSBridge provides the JavaScript interface that allows the web frontend
* to communicate with the Go backend. This is exposed to JavaScript as the
* `window.wails` object.
*
* Similar to iOS's WKScriptMessageHandler but using Android's addJavascriptInterface.
*/
public class WailsJSBridge {
private static final String TAG = "WailsJSBridge";
private final WailsBridge bridge;
private final WebView webView;
public WailsJSBridge(WailsBridge bridge, WebView webView) {
this.bridge = bridge;
this.webView = webView;
}
/**
* Send a message to Go and return the response synchronously.
* Called from JavaScript: wails.invoke(message)
*
* @param message The message to send (JSON string)
* @return The response from Go (JSON string)
*/
@JavascriptInterface
public String invoke(String message) {
Log.d(TAG, "Invoke called: " + message);
return bridge.handleMessage(message);
}
/**
* Send a message to Go asynchronously.
* The response will be sent back via a callback.
* Called from JavaScript: wails.invokeAsync(callbackId, message)
*
* @param callbackId The callback ID to use for the response
* @param message The message to send (JSON string)
*/
@JavascriptInterface
public void invokeAsync(final String callbackId, final String message) {
Log.d(TAG, "InvokeAsync called: " + message);
// Handle in background thread to not block JavaScript
new Thread(() -> {
try {
String response = bridge.handleMessage(message);
sendCallback(callbackId, response, null);
} catch (Exception e) {
Log.e(TAG, "Error in async invoke", e);
sendCallback(callbackId, null, e.getMessage());
}
}).start();
}
/**
* Log a message from JavaScript to Android's logcat
* Called from JavaScript: wails.log(level, message)
*
* @param level The log level (debug, info, warn, error)
* @param message The message to log
*/
@JavascriptInterface
public void log(String level, String message) {
switch (level.toLowerCase()) {
case "debug":
Log.d(TAG + "/JS", message);
break;
case "info":
Log.i(TAG + "/JS", message);
break;
case "warn":
Log.w(TAG + "/JS", message);
break;
case "error":
Log.e(TAG + "/JS", message);
break;
default:
Log.v(TAG + "/JS", message);
break;
}
}
/**
* Get the platform name
* Called from JavaScript: wails.platform()
*
* @return "android"
*/
@JavascriptInterface
public String platform() {
return "android";
}
/**
* Check if we're running in debug mode
* Called from JavaScript: wails.isDebug()
*
* @return true if debug build, false otherwise
*/
@JavascriptInterface
public boolean isDebug() {
return BuildConfig.DEBUG;
}
/**
* Send a callback response to JavaScript
*/
private void sendCallback(String callbackId, String result, String error) {
final String js;
if (error != null) {
js = String.format(
"window.wails && window.wails._callback('%s', null, '%s');",
escapeJsString(callbackId),
escapeJsString(error)
);
} else {
js = String.format(
"window.wails && window.wails._callback('%s', %s, null);",
escapeJsString(callbackId),
result != null ? result : "null"
);
}
webView.post(() -> webView.evaluateJavascript(js, null));
}
private String escapeJsString(String str) {
if (str == null) return "";
return str.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r");
}
}

View File

@ -1,118 +0,0 @@
package com.wails.app;
import android.net.Uri;
import android.util.Log;
import android.webkit.WebResourceResponse;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.webkit.WebViewAssetLoader;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
/**
* WailsPathHandler implements WebViewAssetLoader.PathHandler to serve assets
* from the Go asset server. This allows the WebView to load assets without
* using a network server, similar to iOS's WKURLSchemeHandler.
*/
public class WailsPathHandler implements WebViewAssetLoader.PathHandler {
private static final String TAG = "WailsPathHandler";
private final WailsBridge bridge;
public WailsPathHandler(WailsBridge bridge) {
this.bridge = bridge;
}
@Nullable
@Override
public WebResourceResponse handle(@NonNull String path) {
Log.d(TAG, "Handling path: " + path);
// Normalize path
if (path.isEmpty() || path.equals("/")) {
path = "/index.html";
}
// Get asset from Go
byte[] data = bridge.serveAsset(path, "GET", "{}");
if (data == null || data.length == 0) {
Log.w(TAG, "Asset not found: " + path);
return null; // Return null to let WebView handle 404
}
// Determine MIME type
String mimeType = bridge.getAssetMimeType(path);
Log.d(TAG, "Serving " + path + " with type " + mimeType + " (" + data.length + " bytes)");
// Create response
InputStream inputStream = new ByteArrayInputStream(data);
Map<String, String> headers = new HashMap<>();
headers.put("Access-Control-Allow-Origin", "*");
headers.put("Cache-Control", "no-cache");
return new WebResourceResponse(
mimeType,
"UTF-8",
200,
"OK",
headers,
inputStream
);
}
/**
* Determine MIME type from file extension
*/
private String getMimeType(String path) {
String lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")) {
return "text/html";
} else if (lowerPath.endsWith(".js") || lowerPath.endsWith(".mjs")) {
return "application/javascript";
} else if (lowerPath.endsWith(".css")) {
return "text/css";
} else if (lowerPath.endsWith(".json")) {
return "application/json";
} else if (lowerPath.endsWith(".png")) {
return "image/png";
} else if (lowerPath.endsWith(".jpg") || lowerPath.endsWith(".jpeg")) {
return "image/jpeg";
} else if (lowerPath.endsWith(".gif")) {
return "image/gif";
} else if (lowerPath.endsWith(".svg")) {
return "image/svg+xml";
} else if (lowerPath.endsWith(".ico")) {
return "image/x-icon";
} else if (lowerPath.endsWith(".woff")) {
return "font/woff";
} else if (lowerPath.endsWith(".woff2")) {
return "font/woff2";
} else if (lowerPath.endsWith(".ttf")) {
return "font/ttf";
} else if (lowerPath.endsWith(".eot")) {
return "application/vnd.ms-fontobject";
} else if (lowerPath.endsWith(".xml")) {
return "application/xml";
} else if (lowerPath.endsWith(".txt")) {
return "text/plain";
} else if (lowerPath.endsWith(".wasm")) {
return "application/wasm";
} else if (lowerPath.endsWith(".mp3")) {
return "audio/mpeg";
} else if (lowerPath.endsWith(".mp4")) {
return "video/mp4";
} else if (lowerPath.endsWith(".webm")) {
return "video/webm";
} else if (lowerPath.endsWith(".webp")) {
return "image/webp";
}
return "application/octet-stream";
}
}

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/main_container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="wails_blue">#3574D4</color>
<color name="wails_blue_dark">#2C5FB8</color>
<color name="wails_background">#1B2636</color>
<color name="white">#FFFFFFFF</color>
<color name="black">#FF000000</color>
</resources>

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Wails App</string>
</resources>

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WailsApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/wails_blue</item>
<item name="colorPrimaryVariant">@color/wails_blue_dark</item>
<item name="colorOnPrimary">@android:color/white</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">@color/wails_background</item>
<item name="android:navigationBarColor">@color/wails_background</item>
<!-- Window background -->
<item name="android:windowBackground">@color/wails_background</item>
</style>
</resources>

View File

@ -1,4 +0,0 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.7.3' apply false
}

View File

@ -1,26 +0,0 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/build/optimize-your-build#parallel
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

View File

@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -1,248 +0,0 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

View File

@ -1,93 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -1,11 +0,0 @@
//go:build android
package main
import "github.com/wailsapp/wails/v3/pkg/application"
func init() {
// Register main function to be called when the Android app initializes
// This is necessary because in c-shared build mode, main() is not automatically called
application.RegisterAndroidMain(main)
}

View File

@ -1,151 +0,0 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
func main() {
fmt.Println("Checking Android development dependencies...")
fmt.Println()
errors := []string{}
// Check Go
if !checkCommand("go", "version") {
errors = append(errors, "Go is not installed. Install from https://go.dev/dl/")
} else {
fmt.Println("✓ Go is installed")
}
// Check ANDROID_HOME
androidHome := os.Getenv("ANDROID_HOME")
if androidHome == "" {
androidHome = os.Getenv("ANDROID_SDK_ROOT")
}
if androidHome == "" {
// Try common default locations
home, _ := os.UserHomeDir()
possiblePaths := []string{
filepath.Join(home, "Android", "Sdk"),
filepath.Join(home, "Library", "Android", "sdk"),
"/usr/local/share/android-sdk",
}
for _, p := range possiblePaths {
if _, err := os.Stat(p); err == nil {
androidHome = p
break
}
}
}
if androidHome == "" {
errors = append(errors, "ANDROID_HOME not set. Install Android Studio and set ANDROID_HOME environment variable")
} else {
fmt.Printf("✓ ANDROID_HOME: %s\n", androidHome)
}
// Check adb
if !checkCommand("adb", "version") {
if androidHome != "" {
platformTools := filepath.Join(androidHome, "platform-tools")
errors = append(errors, fmt.Sprintf("adb not found. Add %s to PATH", platformTools))
} else {
errors = append(errors, "adb not found. Install Android SDK Platform-Tools")
}
} else {
fmt.Println("✓ adb is installed")
}
// Check emulator
if !checkCommand("emulator", "-list-avds") {
if androidHome != "" {
emulatorPath := filepath.Join(androidHome, "emulator")
errors = append(errors, fmt.Sprintf("emulator not found. Add %s to PATH", emulatorPath))
} else {
errors = append(errors, "emulator not found. Install Android Emulator via SDK Manager")
}
} else {
fmt.Println("✓ Android Emulator is installed")
}
// Check NDK
ndkHome := os.Getenv("ANDROID_NDK_HOME")
if ndkHome == "" && androidHome != "" {
// Look for NDK in default location
ndkDir := filepath.Join(androidHome, "ndk")
if entries, err := os.ReadDir(ndkDir); err == nil {
for _, entry := range entries {
if entry.IsDir() {
ndkHome = filepath.Join(ndkDir, entry.Name())
break
}
}
}
}
if ndkHome == "" {
errors = append(errors, "Android NDK not found. Install NDK via Android Studio > SDK Manager > SDK Tools > NDK (Side by side)")
} else {
fmt.Printf("✓ Android NDK: %s\n", ndkHome)
}
// Check Java
if !checkCommand("java", "-version") {
errors = append(errors, "Java not found. Install JDK 11+ (OpenJDK recommended)")
} else {
fmt.Println("✓ Java is installed")
}
// Check for AVD (Android Virtual Device)
if checkCommand("emulator", "-list-avds") {
cmd := exec.Command("emulator", "-list-avds")
output, err := cmd.Output()
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
avds := strings.Split(strings.TrimSpace(string(output)), "\n")
fmt.Printf("✓ Found %d Android Virtual Device(s)\n", len(avds))
} else {
fmt.Println("⚠ No Android Virtual Devices found. Create one via Android Studio > Tools > Device Manager")
}
}
fmt.Println()
if len(errors) > 0 {
fmt.Println("❌ Missing dependencies:")
for _, err := range errors {
fmt.Printf(" - %s\n", err)
}
fmt.Println()
fmt.Println("Setup instructions:")
fmt.Println("1. Install Android Studio: https://developer.android.com/studio")
fmt.Println("2. Open SDK Manager and install:")
fmt.Println(" - Android SDK Platform (API 34)")
fmt.Println(" - Android SDK Build-Tools")
fmt.Println(" - Android SDK Platform-Tools")
fmt.Println(" - Android Emulator")
fmt.Println(" - NDK (Side by side)")
fmt.Println("3. Set environment variables:")
if runtime.GOOS == "darwin" {
fmt.Println(" export ANDROID_HOME=$HOME/Library/Android/sdk")
} else {
fmt.Println(" export ANDROID_HOME=$HOME/Android/Sdk")
}
fmt.Println(" export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator")
fmt.Println("4. Create an AVD via Android Studio > Tools > Device Manager")
os.Exit(1)
}
fmt.Println("✓ All Android development dependencies are installed!")
}
func checkCommand(name string, args ...string) bool {
cmd := exec.Command(name, args...)
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run() == nil
}

View File

@ -1,18 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "WailsApp"
include ':app'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

View File

@ -1,78 +0,0 @@
# This file contains the configuration for this project.
# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
# Note that this will overwrite any changes you have made to the assets.
version: '3'
# This information is used to generate the build assets.
info:
companyName: "My Company" # The name of the company
productName: "My Product" # The name of the application
productIdentifier: "com.mycompany.myproduct" # The unique product identifier
description: "A program that does X" # The application description
copyright: "(c) 2025, My Company" # Copyright text
comments: "Some Product Comments" # Comments
version: "0.0.1" # The application version
# iOS build configuration (uncomment to customise iOS project generation)
# Note: Keys under `ios` OVERRIDE values under `info` when set.
# ios:
# # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier)
# bundleID: "com.mycompany.myproduct"
# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
# displayName: "My Product"
# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
# version: "0.0.1"
# # The company/organisation name for templates and project settings
# company: "My Company"
# # Additional comments to embed in Info.plist metadata
# comments: "Some Product Comments"
# Dev mode configuration
dev_mode:
root_path: .
log_level: info # 使用 info 级别,减少非致命错误信息的显示(如 DPI 感知警告)
debounce: 1000
ignore:
dir:
- .git
- node_modules
- frontend
- bin
- assets
- Log
file:
- .DS_Store
- .gitignore
- .gitkeep
- "*.log"
watched_extension:
- "*.go"
- "*.js" # Watch for changes to JS/TS files included using the //wails:include directive.
- "*.ts" # The frontend directory will be excluded entirely by the setting above.
git_ignore: true
executes:
- cmd: wails3 build DEV=true
type: blocking
- cmd: wails3 task common:dev:frontend
type: background
- cmd: wails3 task run
type: primary
# File Associations
# More information at: https://v3.wails.io/noit/done/yet
fileAssociations:
# - ext: wails
# name: Wails
# description: Wails Application File
# iconName: wailsFileIcon
# role: Editor
# - ext: jpg
# name: JPEG
# description: Image File
# iconName: jpegFileIcon
# role: Editor
# mimeType: image/jpeg # (optional)
# Other data
other:
- name: My Other Data

View File

@ -1,32 +0,0 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>My Product</string>
<key>CFBundleExecutable</key>
<string>videoconcat</string>
<key>CFBundleIdentifier</key>
<string>com.example.videoconcat</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleGetInfoString</key>
<string>This is a comment</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>LSMinimumSystemVersion</key>
<string>10.15.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026, My Company</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

View File

@ -1,27 +0,0 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>My Product</string>
<key>CFBundleExecutable</key>
<string>videoconcat</string>
<key>CFBundleIdentifier</key>
<string>com.example.videoconcat</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleGetInfoString</key>
<string>This is a comment</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>LSMinimumSystemVersion</key>
<string>10.15.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026, My Company</string>
</dict>
</plist>

View File

@ -1,199 +0,0 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
# Signing configuration - edit these values for your project
# SIGN_IDENTITY: "Developer ID Application: Your Company (TEAMID)"
# KEYCHAIN_PROFILE: "my-notarize-profile"
# ENTITLEMENTS: "build/darwin/entitlements.plist"
# Docker image for cross-compilation (used when building on non-macOS)
CROSS_IMAGE: wails-cross
tasks:
build:
summary: Builds the application
cmds:
- task: '{{if eq OS "darwin"}}build:native{{else}}build:docker{{end}}'
vars:
ARCH: '{{.ARCH}}'
DEV: '{{.DEV}}'
OUTPUT: '{{.OUTPUT}}'
vars:
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
build:native:
summary: Builds the application natively on macOS
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
- task: common:generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: darwin
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
CGO_CFLAGS: "-mmacosx-version-min=10.15"
CGO_LDFLAGS: "-mmacosx-version-min=10.15"
MACOSX_DEPLOYMENT_TARGET: "10.15"
build:docker:
summary: Cross-compiles for macOS using Docker (for Linux/Windows hosts)
internal: true
deps:
- task: common:build:frontend
- task: common:generate:icons
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required for cross-compilation. Please install Docker."
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
msg: |
Docker image '{{.CROSS_IMAGE}}' not found.
Build it first: wails3 task setup:docker
cmds:
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{.CROSS_IMAGE}} darwin {{.DOCKER_ARCH}}
- docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
- mkdir -p {{.BIN_DIR}}
- mv "bin/{{.APP_NAME}}-darwin-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
vars:
DOCKER_ARCH: '{{if eq .ARCH "arm64"}}arm64{{else if eq .ARCH "amd64"}}amd64{{else}}arm64{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
# Mount Go module cache for faster builds
GO_CACHE_MOUNT:
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
# Extract replace directives from go.mod and create -v mounts for each
# Handles both relative (=> ../) and absolute (=> /) paths
REPLACE_MOUNTS:
sh: |
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
# Convert relative paths to absolute
if [ "${path#/}" = "$path" ]; then
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
fi
# Only mount if directory exists
if [ -d "$path" ]; then
echo "-v $path:$path:ro"
fi
done | tr '\n' ' '
build:universal:
summary: Builds darwin universal binary (arm64 + amd64)
deps:
- task: build
vars:
ARCH: amd64
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
- task: build
vars:
ARCH: arm64
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
cmds:
- task: '{{if eq OS "darwin"}}build:universal:lipo:native{{else}}build:universal:lipo:go{{end}}'
build:universal:lipo:native:
summary: Creates universal binary using native lipo (macOS)
internal: true
cmds:
- lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
- rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
build:universal:lipo:go:
summary: Creates universal binary using wails3 tool lipo (Linux/Windows)
internal: true
cmds:
- wails3 tool lipo -output "{{.BIN_DIR}}/{{.APP_NAME}}" -input "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" -input "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
package:
summary: Packages the application into a `.app` bundle
deps:
- task: build
cmds:
- task: create:app:bundle
package:universal:
summary: Packages darwin universal binary (arm64 + amd64)
deps:
- task: build:universal
cmds:
- task: create:app:bundle
create:app:bundle:
summary: Creates an `.app` bundle
cmds:
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
- cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents"
- task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}'
codesign:adhoc:
summary: Ad-hoc signs the app bundle (macOS only)
internal: true
cmds:
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app"
codesign:skip:
summary: Skips codesigning when cross-compiling
internal: true
cmds:
- 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."'
run:
cmds:
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
- cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}'
sign:
summary: Signs the application bundle with Developer ID
desc: |
Signs the .app bundle for distribution.
Configure SIGN_IDENTITY in the vars section at the top of this file.
deps:
- task: package
cmds:
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}}
preconditions:
- sh: '[ -n "{{.SIGN_IDENTITY}}" ]'
msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
sign:notarize:
summary: Signs and notarizes the application bundle
desc: |
Signs the .app bundle and submits it for notarization.
Configure SIGN_IDENTITY and KEYCHAIN_PROFILE in the vars section at the top of this file.
Setup (one-time):
wails3 signing credentials --apple-id "you@email.com" --team-id "TEAMID" --password "app-specific-password" --profile "my-profile"
deps:
- task: package
cmds:
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}} --notarize --keychain-profile {{.KEYCHAIN_PROFILE}}
preconditions:
- sh: '[ -n "{{.SIGN_IDENTITY}}" ]'
msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
- sh: '[ -n "{{.KEYCHAIN_PROFILE}}" ]'
msg: "KEYCHAIN_PROFILE is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"

Binary file not shown.

View File

@ -1,195 +0,0 @@
# Cross-compile Wails v3 apps to any platform
#
# Uses Zig as C compiler + macOS SDK for darwin targets
#
# Usage:
# docker build -t wails-cross -f Dockerfile.cross .
# docker run --rm -v $(pwd):/app wails-cross darwin arm64
# docker run --rm -v $(pwd):/app wails-cross darwin amd64
# docker run --rm -v $(pwd):/app wails-cross linux amd64
# docker run --rm -v $(pwd):/app wails-cross linux arm64
# docker run --rm -v $(pwd):/app wails-cross windows amd64
# docker run --rm -v $(pwd):/app wails-cross windows arm64
FROM golang:1.25-alpine
RUN apk add --no-cache curl xz nodejs npm
# Install Zig
ARG ZIG_VERSION=0.14.0
RUN curl -L "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz" \
| tar -xJ -C /opt \
&& ln -s /opt/zig-linux-x86_64-${ZIG_VERSION}/zig /usr/local/bin/zig
# Download macOS SDK (required for darwin targets)
ARG MACOS_SDK_VERSION=14.5
RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" \
| tar -xJ -C /opt \
&& mv /opt/MacOSX${MACOS_SDK_VERSION}.sdk /opt/macos-sdk
ENV MACOS_SDK_PATH=/opt/macos-sdk
# Create zig cc wrappers for each target
# Darwin arm64
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-mmacosx-version-min=*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -fno-sanitize=all -target aarch64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-darwin-arm64
# Darwin amd64
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-amd64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-mmacosx-version-min=*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -fno-sanitize=all -target x86_64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-darwin-amd64
# Linux amd64
COPY <<'ZIGWRAP' /usr/local/bin/zcc-linux-amd64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -target x86_64-linux-musl $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-linux-amd64
# Linux arm64
COPY <<'ZIGWRAP' /usr/local/bin/zcc-linux-arm64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -target aarch64-linux-musl $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-linux-arm64
# Windows amd64
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-amd64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-Wl,*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -target x86_64-windows-gnu $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-windows-amd64
# Windows arm64
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-arm64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-Wl,*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -target aarch64-windows-gnu $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-windows-arm64
# Build script
COPY <<'SCRIPT' /usr/local/bin/build.sh
#!/bin/sh
set -e
OS=${1:-darwin}
ARCH=${2:-arm64}
case "${OS}-${ARCH}" in
darwin-arm64|darwin-aarch64) export CC=zcc-darwin-arm64; export GOARCH=arm64; export GOOS=darwin ;;
darwin-amd64|darwin-x86_64) export CC=zcc-darwin-amd64; export GOARCH=amd64; export GOOS=darwin ;;
linux-arm64|linux-aarch64) export CC=zcc-linux-arm64; export GOARCH=arm64; export GOOS=linux ;;
linux-amd64|linux-x86_64) export CC=zcc-linux-amd64; export GOARCH=amd64; export GOOS=linux ;;
windows-arm64|windows-aarch64) export CC=zcc-windows-arm64; export GOARCH=arm64; export GOOS=windows ;;
windows-amd64|windows-x86_64) export CC=zcc-windows-amd64; export GOARCH=amd64; export GOOS=windows ;;
*) echo "Usage: <os> <arch>"; echo " os: darwin, linux, windows"; echo " arch: amd64, arm64"; exit 1 ;;
esac
export CGO_ENABLED=1
export CGO_CFLAGS="-w"
# Build frontend if exists and not already built (host may have built it)
if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
(cd frontend && npm install --silent && npm run build --silent)
fi
# Build
APP=${APP_NAME:-$(basename $(pwd))}
mkdir -p bin
EXT=""
LDFLAGS="-s -w"
if [ "$GOOS" = "windows" ]; then
EXT=".exe"
LDFLAGS="-s -w -H windowsgui"
fi
go build -ldflags="$LDFLAGS" -o bin/${APP}-${GOOS}-${GOARCH}${EXT} .
echo "Built: bin/${APP}-${GOOS}-${GOARCH}${EXT}"
SCRIPT
RUN chmod +x /usr/local/bin/build.sh
WORKDIR /app
ENTRYPOINT ["/usr/local/bin/build.sh"]
CMD ["darwin", "arm64"]

View File

@ -1,116 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"images" : [
{
"filename" : "icon-20@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon-20@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "icon-29@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon-29@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "icon-40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon-40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "icon-60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "icon-60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "icon-20.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "icon-20@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon-29.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "icon-29@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon-40.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "icon-40@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon-76.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "icon-76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "icon-83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "icon-1024.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
]
}

View File

@ -1,62 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>videoconcat</string>
<key>CFBundleIdentifier</key>
<string>com.example.videoconcat.dev</string>
<key>CFBundleName</key>
<string>My Product (Dev)</string>
<key>CFBundleDisplayName</key>
<string>My Product (Dev)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0-dev</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MinimumOSVersion</key>
<string>15.0</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<!-- Development mode enabled -->
<key>WailsDevelopmentMode</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© 2026, My Company</string>
<key>CFBundleGetInfoString</key>
<string>This is a comment</string>
</dict>
</plist>

View File

@ -1,59 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>videoconcat</string>
<key>CFBundleIdentifier</key>
<string>com.example.videoconcat</string>
<key>CFBundleName</key>
<string>My Product</string>
<key>CFBundleDisplayName</key>
<string>My Product</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MinimumOSVersion</key>
<string>15.0</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSHumanReadableCopyright</key>
<string>© 2026, My Company</string>
<key>CFBundleGetInfoString</key>
<string>This is a comment</string>
</dict>
</plist>

View File

@ -1,53 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="My Product" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
<rect key="frame" x="0.0" y="397" width="393" height="43"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="36"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="A VideoConcat application" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="MN2-I3-ftu">
<rect key="frame" x="0.0" y="448" width="393" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="GJd-Yh-RWb" secondAttribute="centerX" id="Q3B-4B-g5h"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="bottom" multiplier="1/2" constant="-20" id="moa-c2-u7t"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="x7j-FC-K8j"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="top" secondItem="GJd-Yh-RWb" secondAttribute="bottom" constant="8" symbolic="YES" id="cPy-rs-vsC"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="centerX" secondItem="Bcu-3y-fUS" secondAttribute="centerX" id="OQL-iM-xY6"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="Dti-5h-tvW"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
</document>

View File

@ -1,293 +0,0 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
BUNDLE_ID: '{{.BUNDLE_ID | default "com.wails.app"}}'
# SDK_PATH is computed lazily at task-level to avoid errors on non-macOS systems
# Each task that needs it defines SDK_PATH in its own vars section
tasks:
install:deps:
summary: Check and install iOS development dependencies
cmds:
- go run build/ios/scripts/deps/install_deps.go
env:
TASK_FORCE_YES: '{{if .YES}}true{{else}}false{{end}}'
prompt: This will check and install iOS development dependencies. Continue?
# Note: Bindings generation may show CGO warnings for iOS C imports.
# These warnings are harmless and don't affect the generated bindings,
# as the generator only needs to parse Go types, not C implementations.
build:
summary: Creates a build of the application for iOS
deps:
- task: generate:ios:overlay
- task: generate:ios:xcode
- task: common:go:mod:tidy
- task: generate:ios:bindings
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
PRODUCTION:
ref: .PRODUCTION
- task: common:generate:icons
cmds:
- echo "Building iOS app {{.APP_NAME}}..."
- go build -buildmode=c-archive -overlay build/ios/xcode/overlay.json {{.BUILD_FLAGS}} -o {{.OUTPUT}}.a
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,ios -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags ios,debug -buildvcs=false -gcflags=all="-l"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
SDK_PATH:
sh: xcrun --sdk iphonesimulator --show-sdk-path
env:
GOOS: ios
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default "arm64"}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
CGO_CFLAGS: '-isysroot {{.SDK_PATH}} -target arm64-apple-ios15.0-simulator -mios-simulator-version-min=15.0'
CGO_LDFLAGS: '-isysroot {{.SDK_PATH}} -target arm64-apple-ios15.0-simulator'
compile:objc:
summary: Compile Objective-C iOS wrapper
vars:
SDK_PATH:
sh: xcrun --sdk iphonesimulator --show-sdk-path
cmds:
- xcrun -sdk iphonesimulator clang -target arm64-apple-ios15.0-simulator -isysroot {{.SDK_PATH}} -framework Foundation -framework UIKit -framework WebKit -o {{.BIN_DIR}}/{{.APP_NAME}} build/ios/main.m
- codesign --force --sign - "{{.BIN_DIR}}/{{.APP_NAME}}"
package:
summary: Packages a production build of the application into a `.app` bundle
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: create:app:bundle
create:app:bundle:
summary: Creates an iOS `.app` bundle
cmds:
- rm -rf "{{.BIN_DIR}}/{{.APP_NAME}}.app"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app"
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/"
- cp build/ios/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/"
- |
# Compile asset catalog and embed icons in the app bundle
APP_BUNDLE="{{.BIN_DIR}}/{{.APP_NAME}}.app"
AC_IN="build/ios/xcode/main/Assets.xcassets"
if [ -d "$AC_IN" ]; then
TMP_AC=$(mktemp -d)
xcrun actool \
--compile "$TMP_AC" \
--app-icon AppIcon \
--platform iphonesimulator \
--minimum-deployment-target 15.0 \
--product-type com.apple.product-type.application \
--target-device iphone \
--target-device ipad \
--output-partial-info-plist "$APP_BUNDLE/assetcatalog_generated_info.plist" \
"$AC_IN"
if [ -f "$TMP_AC/Assets.car" ]; then
cp -f "$TMP_AC/Assets.car" "$APP_BUNDLE/Assets.car"
fi
rm -rf "$TMP_AC"
if [ -f "$APP_BUNDLE/assetcatalog_generated_info.plist" ]; then
/usr/libexec/PlistBuddy -c "Merge $APP_BUNDLE/assetcatalog_generated_info.plist" "$APP_BUNDLE/Info.plist" || true
fi
fi
- codesign --force --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app"
deploy-simulator:
summary: Deploy to iOS Simulator
deps: [package]
cmds:
- xcrun simctl terminate booted {{.BUNDLE_ID}} 2>/dev/null || true
- xcrun simctl uninstall booted {{.BUNDLE_ID}} 2>/dev/null || true
- xcrun simctl install booted "{{.BIN_DIR}}/{{.APP_NAME}}.app"
- xcrun simctl launch booted {{.BUNDLE_ID}}
compile:ios:
summary: Compile the iOS executable from Go archive and main.m
deps:
- task: build
vars:
SDK_PATH:
sh: xcrun --sdk iphonesimulator --show-sdk-path
cmds:
- |
MAIN_M=build/ios/xcode/main/main.m
if [ ! -f "$MAIN_M" ]; then
MAIN_M=build/ios/main.m
fi
xcrun -sdk iphonesimulator clang \
-target arm64-apple-ios15.0-simulator \
-isysroot {{.SDK_PATH}} \
-framework Foundation -framework UIKit -framework WebKit \
-framework Security -framework CoreFoundation \
-lresolv \
-o "{{.BIN_DIR}}/{{.APP_NAME | lower}}" \
"$MAIN_M" "{{.BIN_DIR}}/{{.APP_NAME}}.a"
generate:ios:bindings:
internal: true
summary: Generates bindings for iOS with proper CGO flags
sources:
- "**/*.go"
- go.mod
- go.sum
generates:
- frontend/bindings/**/*
vars:
SDK_PATH:
sh: xcrun --sdk iphonesimulator --show-sdk-path
cmds:
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
env:
GOOS: ios
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default "arm64"}}'
CGO_CFLAGS: '-isysroot {{.SDK_PATH}} -target arm64-apple-ios15.0-simulator -mios-simulator-version-min=15.0'
CGO_LDFLAGS: '-isysroot {{.SDK_PATH}} -target arm64-apple-ios15.0-simulator'
ensure-simulator:
internal: true
summary: Ensure iOS Simulator is running and booted
silent: true
cmds:
- |
if ! xcrun simctl list devices booted | grep -q "Booted"; then
echo "Starting iOS Simulator..."
# Get first available iPhone device
DEVICE_ID=$(xcrun simctl list devices available | grep "iPhone" | head -1 | grep -o "[A-F0-9-]\{36\}" || true)
if [ -z "$DEVICE_ID" ]; then
echo "No iPhone simulator found. Creating one..."
RUNTIME=$(xcrun simctl list runtimes | grep iOS | tail -1 | awk '{print $NF}')
DEVICE_ID=$(xcrun simctl create "iPhone 15 Pro" "iPhone 15 Pro" "$RUNTIME")
fi
# Boot the device
echo "Booting device $DEVICE_ID..."
xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true
# Open Simulator app
open -a Simulator
# Wait for boot (max 30 seconds)
for i in {1..30}; do
if xcrun simctl list devices booted | grep -q "Booted"; then
echo "Simulator booted successfully"
break
fi
sleep 1
done
# Final check
if ! xcrun simctl list devices booted | grep -q "Booted"; then
echo "Failed to boot simulator after 30 seconds"
exit 1
fi
fi
preconditions:
- sh: command -v xcrun
msg: "xcrun not found. Please run 'wails3 task ios:install:deps' to install iOS development dependencies"
generate:ios:overlay:
internal: true
summary: Generate Go build overlay and iOS shim
sources:
- build/config.yml
generates:
- build/ios/xcode/overlay.json
- build/ios/xcode/gen/main_ios.gen.go
cmds:
- wails3 ios overlay:gen -out build/ios/xcode/overlay.json -config build/config.yml
generate:ios:xcode:
internal: true
summary: Generate iOS Xcode project structure and assets
sources:
- build/config.yml
- build/appicon.png
generates:
- build/ios/xcode/main/main.m
- build/ios/xcode/main/Assets.xcassets/**/*
- build/ios/xcode/project.pbxproj
cmds:
- wails3 ios xcode:gen -outdir build/ios/xcode -config build/config.yml
run:
summary: Run the application in iOS Simulator
deps:
- task: ensure-simulator
- task: compile:ios
cmds:
- rm -rf "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- cp "{{.BIN_DIR}}/{{.APP_NAME | lower}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/{{.APP_NAME | lower}}"
- cp build/ios/Info.dev.plist "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Info.plist"
- |
# Compile asset catalog and embed icons for dev bundle
APP_BUNDLE="{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
AC_IN="build/ios/xcode/main/Assets.xcassets"
if [ -d "$AC_IN" ]; then
TMP_AC=$(mktemp -d)
xcrun actool \
--compile "$TMP_AC" \
--app-icon AppIcon \
--platform iphonesimulator \
--minimum-deployment-target 15.0 \
--product-type com.apple.product-type.application \
--target-device iphone \
--target-device ipad \
--output-partial-info-plist "$APP_BUNDLE/assetcatalog_generated_info.plist" \
"$AC_IN"
if [ -f "$TMP_AC/Assets.car" ]; then
cp -f "$TMP_AC/Assets.car" "$APP_BUNDLE/Assets.car"
fi
rm -rf "$TMP_AC"
if [ -f "$APP_BUNDLE/assetcatalog_generated_info.plist" ]; then
/usr/libexec/PlistBuddy -c "Merge $APP_BUNDLE/assetcatalog_generated_info.plist" "$APP_BUNDLE/Info.plist" || true
fi
fi
- codesign --force --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- xcrun simctl terminate booted "com.wails.{{.APP_NAME | lower}}.dev" 2>/dev/null || true
- xcrun simctl uninstall booted "com.wails.{{.APP_NAME | lower}}.dev" 2>/dev/null || true
- xcrun simctl install booted "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- xcrun simctl launch booted "com.wails.{{.APP_NAME | lower}}.dev"
xcode:
summary: Open the generated Xcode project for this app
cmds:
- task: generate:ios:xcode
- open build/ios/xcode/main.xcodeproj
logs:
summary: Stream iOS Simulator logs filtered to this app
cmds:
- |
xcrun simctl spawn booted log stream \
--level debug \
--style compact \
--predicate 'senderImagePath CONTAINS[c] "{{.APP_NAME | lower}}.app/" OR composedMessage CONTAINS[c] "{{.APP_NAME | lower}}" OR eventMessage CONTAINS[c] "{{.APP_NAME | lower}}" OR process == "{{.APP_NAME | lower}}" OR category CONTAINS[c] "{{.APP_NAME | lower}}"'
logs:dev:
summary: Stream logs for the dev bundle (used by `task ios:run`)
cmds:
- |
xcrun simctl spawn booted log stream \
--level debug \
--style compact \
--predicate 'senderImagePath CONTAINS[c] ".dev.app/" OR subsystem == "com.wails.{{.APP_NAME | lower}}.dev" OR process == "{{.APP_NAME | lower}}"'
logs:wide:
summary: Wide log stream to help discover the exact process/bundle identifiers
cmds:
- |
xcrun simctl spawn booted log stream \
--level debug \
--style compact \
--predicate 'senderImagePath CONTAINS[c] ".app/"'

View File

@ -1,10 +0,0 @@
//go:build !ios
package main
import "github.com/wailsapp/wails/v3/pkg/application"
// modifyOptionsForIOS is a no-op on non-iOS platforms
func modifyOptionsForIOS(opts *application.Options) {
// No modifications needed for non-iOS platforms
}

View File

@ -1,11 +0,0 @@
//go:build ios
package main
import "github.com/wailsapp/wails/v3/pkg/application"
// modifyOptionsForIOS adjusts the application options for iOS
func modifyOptionsForIOS(opts *application.Options) {
// Disable signal handlers on iOS to prevent crashes
opts.DisableDefaultSignalHandler = true
}

View File

@ -1,72 +0,0 @@
#!/bin/bash
set -e
# Build configuration
APP_NAME="videoconcat"
BUNDLE_ID="com.example.videoconcat"
VERSION="0.1.0"
BUILD_NUMBER="0.1.0"
BUILD_DIR="build/ios"
TARGET="simulator"
echo "Building iOS app: $APP_NAME"
echo "Bundle ID: $BUNDLE_ID"
echo "Version: $VERSION ($BUILD_NUMBER)"
echo "Target: $TARGET"
# Ensure build directory exists
mkdir -p "$BUILD_DIR"
# Determine SDK and target architecture
if [ "$TARGET" = "simulator" ]; then
SDK="iphonesimulator"
ARCH="arm64-apple-ios15.0-simulator"
elif [ "$TARGET" = "device" ]; then
SDK="iphoneos"
ARCH="arm64-apple-ios15.0"
else
echo "Unknown target: $TARGET"
exit 1
fi
# Get SDK path
SDK_PATH=$(xcrun --sdk $SDK --show-sdk-path)
# Compile the application
echo "Compiling with SDK: $SDK"
xcrun -sdk $SDK clang \
-target $ARCH \
-isysroot "$SDK_PATH" \
-framework Foundation \
-framework UIKit \
-framework WebKit \
-framework CoreGraphics \
-o "$BUILD_DIR/$APP_NAME" \
"$BUILD_DIR/main.m"
# Create app bundle
echo "Creating app bundle..."
APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
rm -rf "$APP_BUNDLE"
mkdir -p "$APP_BUNDLE"
# Move executable
mv "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/"
# Copy Info.plist
cp "$BUILD_DIR/Info.plist" "$APP_BUNDLE/"
# Sign the app
echo "Signing app..."
codesign --force --sign - "$APP_BUNDLE"
echo "Build complete: $APP_BUNDLE"
# Deploy to simulator if requested
if [ "$TARGET" = "simulator" ]; then
echo "Deploying to simulator..."
xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true
xcrun simctl install booted "$APP_BUNDLE"
xcrun simctl launch booted "$BUNDLE_ID"
echo "App launched on simulator"
fi

View File

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Development entitlements -->
<key>get-task-allow</key>
<true/>
<!-- App Sandbox -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Network access -->
<key>com.apple.security.network.client</key>
<true/>
<!-- File access (read-only) -->
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>

View File

@ -1,3 +0,0 @@
# iOS Icon Placeholder
# This file should be replaced with the actual app icon (1024x1024 PNG)
# The build process will generate all required icon sizes from this base icon

View File

@ -1,23 +0,0 @@
//go:build ios
// Minimal bootstrap: delegate comes from Go archive (WailsAppDelegate)
#import <UIKit/UIKit.h>
#include <stdio.h>
// External Go initialization function from the c-archive (declare before use)
extern void WailsIOSMain();
int main(int argc, char * argv[]) {
@autoreleasepool {
// Disable buffering so stdout/stderr from Go log.Printf flush immediately
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
// Start Go runtime on a background queue to avoid blocking main thread/UI
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
WailsIOSMain();
});
// Run UIApplicationMain using WailsAppDelegate provided by the Go archive
return UIApplicationMain(argc, argv, nil, @"WailsAppDelegate");
}
}

View File

@ -1,24 +0,0 @@
//go:build ios
package main
import (
"C"
)
// For iOS builds, we need to export a function that can be called from Objective-C
// This wrapper allows us to keep the original main.go unmodified
//export WailsIOSMain
func WailsIOSMain() {
// DO NOT lock the goroutine to the current OS thread on iOS!
// This causes signal handling issues:
// "signal 16 received on thread with no signal stack"
// "fatal error: non-Go code disabled sigaltstack"
// iOS apps run in a sandboxed environment where the Go runtime's
// signal handling doesn't work the same way as desktop platforms.
// Call the actual main function from main.go
// This ensures all the user's code is executed
main()
}

View File

@ -1,222 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
C0DEBEEF0000000000000001 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000002 /* main.m */; };
C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000101 /* UIKit.framework */; };
C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000102 /* Foundation.framework */; };
C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000103 /* WebKit.framework */; };
C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000104 /* Security.framework */; };
C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000105 /* CoreFoundation.framework */; };
C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000106 /* libresolv.tbd */; };
C0DEBEEF00000000000000F7 /* My Product.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000107 /* My Product.a */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
C0DEBEEF0000000000000002 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
C0DEBEEF0000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C0DEBEEF0000000000000004 /* My Product.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "My Product.app"; sourceTree = BUILT_PRODUCTS_DIR; };
C0DEBEEF0000000000000101 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
C0DEBEEF0000000000000102 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; };
C0DEBEEF0000000000000103 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
C0DEBEEF0000000000000104 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
C0DEBEEF0000000000000105 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
C0DEBEEF0000000000000106 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.text-based-dylib-definition; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; };
C0DEBEEF0000000000000107 /* My Product.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "My Product.a"; path = ../../../bin/My Product.a; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
C0DEBEEF0000000000000010 = {
isa = PBXGroup;
children = (
C0DEBEEF0000000000000020 /* Products */,
C0DEBEEF0000000000000045 /* Frameworks */,
C0DEBEEF0000000000000030 /* main */,
);
sourceTree = "<group>";
};
C0DEBEEF0000000000000020 /* Products */ = {
isa = PBXGroup;
children = (
C0DEBEEF0000000000000004 /* My Product.app */,
);
name = Products;
sourceTree = "<group>";
};
C0DEBEEF0000000000000030 /* main */ = {
isa = PBXGroup;
children = (
C0DEBEEF0000000000000002 /* main.m */,
C0DEBEEF0000000000000003 /* Info.plist */,
);
path = main;
sourceTree = SOURCE_ROOT;
};
C0DEBEEF0000000000000045 /* Frameworks */ = {
isa = PBXGroup;
children = (
C0DEBEEF0000000000000101 /* UIKit.framework */,
C0DEBEEF0000000000000102 /* Foundation.framework */,
C0DEBEEF0000000000000103 /* WebKit.framework */,
C0DEBEEF0000000000000104 /* Security.framework */,
C0DEBEEF0000000000000105 /* CoreFoundation.framework */,
C0DEBEEF0000000000000106 /* libresolv.tbd */,
C0DEBEEF0000000000000107 /* My Product.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
C0DEBEEF0000000000000040 /* My Product */ = {
isa = PBXNativeTarget;
buildConfigurationList = C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "My Product" */;
buildPhases = (
C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */,
C0DEBEEF0000000000000050 /* Sources */,
C0DEBEEF0000000000000056 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = "My Product";
productName = "My Product";
productReference = C0DEBEEF0000000000000004 /* My Product.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
C0DEBEEF0000000000000060 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1500;
ORGANIZATIONNAME = "My Company";
TargetAttributes = {
C0DEBEEF0000000000000040 = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */;
compatibilityVersion = "Xcode 15.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
);
mainGroup = C0DEBEEF0000000000000010;
productRefGroup = C0DEBEEF0000000000000020 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
C0DEBEEF0000000000000040 /* My Product */,
);
};
/* End PBXProject section */
/* Begin PBXFrameworksBuildPhase section */
C0DEBEEF0000000000000056 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C0DEBEEF00000000000000F7 /* My Product.a in Frameworks */,
C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */,
C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */,
C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */,
C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */,
C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */,
C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Prebuild: Wails Go Archive";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\nAPP_ROOT=\"${PROJECT_DIR}/../../..\"\nSDK_PATH=$(xcrun --sdk iphonesimulator --show-sdk-path)\nexport GOOS=ios\nexport GOARCH=arm64\nexport CGO_ENABLED=1\nexport CGO_CFLAGS=\"-isysroot ${SDK_PATH} -target arm64-apple-ios15.0-simulator -mios-simulator-version-min=15.0\"\nexport CGO_LDFLAGS=\"-isysroot ${SDK_PATH} -target arm64-apple-ios15.0-simulator\"\ncd \"${APP_ROOT}\"\n# Ensure overlay exists\nif [ ! -f build/ios/xcode/overlay.json ]; then\n wails3 ios overlay:gen -out build/ios/xcode/overlay.json -config build/config.yml || true\nfi\n# Build Go c-archive if missing or older than sources\nif [ ! -f bin/My Product.a ]; then\n echo \"Building Go c-archive...\"\n go build -buildmode=c-archive -overlay build/ios/xcode/overlay.json -o bin/My Product.a\nfi\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
C0DEBEEF0000000000000050 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C0DEBEEF0000000000000001 /* main.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
C0DEBEEF0000000000000090 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
INFOPLIST_FILE = main/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.example.videoconcat";
PRODUCT_NAME = "My Product";
CODE_SIGNING_ALLOWED = NO;
SDKROOT = iphonesimulator;
};
name = Debug;
};
C0DEBEEF00000000000000A0 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
INFOPLIST_FILE = main/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.example.videoconcat";
PRODUCT_NAME = "My Product";
CODE_SIGNING_ALLOWED = NO;
SDKROOT = iphonesimulator;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "My Product" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C0DEBEEF0000000000000090 /* Debug */,
C0DEBEEF00000000000000A0 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C0DEBEEF0000000000000090 /* Debug */,
C0DEBEEF00000000000000A0 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = C0DEBEEF0000000000000060 /* Project object */;
}

View File

@ -1,319 +0,0 @@
// install_deps.go - iOS development dependency checker
// This script checks for required iOS development tools.
// It's designed to be portable across different shells by using Go instead of shell scripts.
//
// Usage:
// go run install_deps.go # Interactive mode
// TASK_FORCE_YES=true go run install_deps.go # Auto-accept prompts
// CI=true go run install_deps.go # CI mode (auto-accept)
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"strings"
)
type Dependency struct {
Name string
CheckFunc func() (bool, string) // Returns (success, details)
Required bool
InstallCmd []string
InstallMsg string
SuccessMsg string
FailureMsg string
}
func main() {
fmt.Println("Checking iOS development dependencies...")
fmt.Println("=" + strings.Repeat("=", 50))
fmt.Println()
hasErrors := false
dependencies := []Dependency{
{
Name: "Xcode",
CheckFunc: func() (bool, string) {
// Check if xcodebuild exists
if !checkCommand([]string{"xcodebuild", "-version"}) {
return false, ""
}
// Get version info
out, err := exec.Command("xcodebuild", "-version").Output()
if err != nil {
return false, ""
}
lines := strings.Split(string(out), "\n")
if len(lines) > 0 {
return true, strings.TrimSpace(lines[0])
}
return true, ""
},
Required: true,
InstallMsg: "Please install Xcode from the Mac App Store:\n https://apps.apple.com/app/xcode/id497799835\n Xcode is REQUIRED for iOS development (includes iOS SDKs, simulators, and frameworks)",
SuccessMsg: "✅ Xcode found",
FailureMsg: "❌ Xcode not found (REQUIRED)",
},
{
Name: "Xcode Developer Path",
CheckFunc: func() (bool, string) {
// Check if xcode-select points to a valid Xcode path
out, err := exec.Command("xcode-select", "-p").Output()
if err != nil {
return false, "xcode-select not configured"
}
path := strings.TrimSpace(string(out))
// Check if path exists and is in Xcode.app
if _, err := os.Stat(path); err != nil {
return false, "Invalid Xcode path"
}
// Verify it's pointing to Xcode.app (not just Command Line Tools)
if !strings.Contains(path, "Xcode.app") {
return false, fmt.Sprintf("Points to %s (should be Xcode.app)", path)
}
return true, path
},
Required: true,
InstallCmd: []string{"sudo", "xcode-select", "-s", "/Applications/Xcode.app/Contents/Developer"},
InstallMsg: "Xcode developer path needs to be configured",
SuccessMsg: "✅ Xcode developer path configured",
FailureMsg: "❌ Xcode developer path not configured correctly",
},
{
Name: "iOS SDK",
CheckFunc: func() (bool, string) {
// Get the iOS Simulator SDK path
cmd := exec.Command("xcrun", "--sdk", "iphonesimulator", "--show-sdk-path")
output, err := cmd.Output()
if err != nil {
return false, "Cannot find iOS SDK"
}
sdkPath := strings.TrimSpace(string(output))
// Check if the SDK path exists
if _, err := os.Stat(sdkPath); err != nil {
return false, "iOS SDK path not found"
}
// Check for UIKit framework (essential for iOS development)
uikitPath := fmt.Sprintf("%s/System/Library/Frameworks/UIKit.framework", sdkPath)
if _, err := os.Stat(uikitPath); err != nil {
return false, "UIKit.framework not found"
}
// Get SDK version
versionCmd := exec.Command("xcrun", "--sdk", "iphonesimulator", "--show-sdk-version")
versionOut, _ := versionCmd.Output()
version := strings.TrimSpace(string(versionOut))
return true, fmt.Sprintf("iOS %s SDK", version)
},
Required: true,
InstallMsg: "iOS SDK comes with Xcode. Please ensure Xcode is properly installed.",
SuccessMsg: "✅ iOS SDK found with UIKit framework",
FailureMsg: "❌ iOS SDK not found or incomplete",
},
{
Name: "iOS Simulator Runtime",
CheckFunc: func() (bool, string) {
if !checkCommand([]string{"xcrun", "simctl", "help"}) {
return false, ""
}
// Check if we can list runtimes
out, err := exec.Command("xcrun", "simctl", "list", "runtimes").Output()
if err != nil {
return false, "Cannot access simulator"
}
// Count iOS runtimes
lines := strings.Split(string(out), "\n")
count := 0
var versions []string
for _, line := range lines {
if strings.Contains(line, "iOS") && !strings.Contains(line, "unavailable") {
count++
// Extract version number
if parts := strings.Fields(line); len(parts) > 2 {
for _, part := range parts {
if strings.HasPrefix(part, "(") && strings.HasSuffix(part, ")") {
versions = append(versions, strings.Trim(part, "()"))
break
}
}
}
}
}
if count > 0 {
return true, fmt.Sprintf("%d runtime(s): %s", count, strings.Join(versions, ", "))
}
return false, "No iOS runtimes installed"
},
Required: true,
InstallMsg: "iOS Simulator runtimes come with Xcode. You may need to download them:\n Xcode → Settings → Platforms → iOS",
SuccessMsg: "✅ iOS Simulator runtime available",
FailureMsg: "❌ iOS Simulator runtime not available",
},
}
// Check each dependency
for _, dep := range dependencies {
success, details := dep.CheckFunc()
if success {
msg := dep.SuccessMsg
if details != "" {
msg = fmt.Sprintf("%s (%s)", dep.SuccessMsg, details)
}
fmt.Println(msg)
} else {
fmt.Println(dep.FailureMsg)
if details != "" {
fmt.Printf(" Details: %s\n", details)
}
if dep.Required {
hasErrors = true
if len(dep.InstallCmd) > 0 {
fmt.Println()
fmt.Println(" " + dep.InstallMsg)
fmt.Printf(" Fix command: %s\n", strings.Join(dep.InstallCmd, " "))
if promptUser("Do you want to run this command?") {
fmt.Println("Running command...")
cmd := exec.Command(dep.InstallCmd[0], dep.InstallCmd[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
fmt.Printf("Command failed: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ Command completed. Please run this check again.")
} else {
fmt.Printf(" Please run manually: %s\n", strings.Join(dep.InstallCmd, " "))
}
} else {
fmt.Println(" " + dep.InstallMsg)
}
}
}
}
// Check for iPhone simulators
fmt.Println()
fmt.Println("Checking for iPhone simulator devices...")
if !checkCommand([]string{"xcrun", "simctl", "list", "devices"}) {
fmt.Println("❌ Cannot check for iPhone simulators")
hasErrors = true
} else {
out, err := exec.Command("xcrun", "simctl", "list", "devices").Output()
if err != nil {
fmt.Println("❌ Failed to list simulator devices")
hasErrors = true
} else if !strings.Contains(string(out), "iPhone") {
fmt.Println("⚠️ No iPhone simulator devices found")
fmt.Println()
// Get the latest iOS runtime
runtimeOut, err := exec.Command("xcrun", "simctl", "list", "runtimes").Output()
if err != nil {
fmt.Println(" Failed to get iOS runtimes:", err)
} else {
lines := strings.Split(string(runtimeOut), "\n")
var latestRuntime string
for _, line := range lines {
if strings.Contains(line, "iOS") && !strings.Contains(line, "unavailable") {
// Extract runtime identifier
parts := strings.Fields(line)
if len(parts) > 0 {
latestRuntime = parts[len(parts)-1]
}
}
}
if latestRuntime == "" {
fmt.Println(" No iOS runtime found. Please install iOS simulators in Xcode:")
fmt.Println(" Xcode → Settings → Platforms → iOS")
} else {
fmt.Println(" Would you like to create an iPhone 15 Pro simulator?")
createCmd := []string{"xcrun", "simctl", "create", "iPhone 15 Pro", "iPhone 15 Pro", latestRuntime}
fmt.Printf(" Command: %s\n", strings.Join(createCmd, " "))
if promptUser("Create simulator?") {
cmd := exec.Command(createCmd[0], createCmd[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf(" Failed to create simulator: %v\n", err)
} else {
fmt.Println(" ✅ iPhone 15 Pro simulator created")
}
} else {
fmt.Println(" Skipping simulator creation")
fmt.Printf(" Create manually: %s\n", strings.Join(createCmd, " "))
}
}
}
} else {
// Count iPhone devices
count := 0
lines := strings.Split(string(out), "\n")
for _, line := range lines {
if strings.Contains(line, "iPhone") && !strings.Contains(line, "unavailable") {
count++
}
}
fmt.Printf("✅ %d iPhone simulator device(s) available\n", count)
}
}
// Final summary
fmt.Println()
fmt.Println("=" + strings.Repeat("=", 50))
if hasErrors {
fmt.Println("❌ Some required dependencies are missing or misconfigured.")
fmt.Println()
fmt.Println("Quick setup guide:")
fmt.Println("1. Install Xcode from Mac App Store (if not installed)")
fmt.Println("2. Open Xcode once and agree to the license")
fmt.Println("3. Install additional components when prompted")
fmt.Println("4. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer")
fmt.Println("5. Download iOS simulators: Xcode → Settings → Platforms → iOS")
fmt.Println("6. Run this check again")
os.Exit(1)
} else {
fmt.Println("✅ All required dependencies are installed!")
fmt.Println(" You're ready for iOS development with Wails!")
}
}
func checkCommand(args []string) bool {
if len(args) == 0 {
return false
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout = nil
cmd.Stderr = nil
err := cmd.Run()
return err == nil
}
func promptUser(question string) bool {
// Check if we're in a non-interactive environment
if os.Getenv("CI") != "" || os.Getenv("TASK_FORCE_YES") == "true" {
fmt.Printf("%s [y/N]: y (auto-accepted)\n", question)
return true
}
reader := bufio.NewReader(os.Stdin)
fmt.Printf("%s [y/N]: ", question)
response, err := reader.ReadString('\n')
if err != nil {
return false
}
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes"
}

View File

@ -1,220 +0,0 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
# Signing configuration - edit these values for your project
# PGP_KEY: "path/to/signing-key.asc"
# SIGN_ROLE: "builder" # Options: origin, maint, archive, builder
#
# Password is stored securely in system keychain. Run: wails3 setup signing
# Docker image for cross-compilation (used when building on non-Linux or no CC available)
CROSS_IMAGE: wails-cross
tasks:
build:
summary: Builds the application for Linux
cmds:
# Linux requires CGO - use Docker when cross-compiling from non-Linux OR when no C compiler is available
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true")}}build:native{{else}}build:docker{{end}}'
vars:
ARCH: '{{.ARCH}}'
DEV: '{{.DEV}}'
OUTPUT: '{{.OUTPUT}}'
vars:
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
# Check if a C compiler is available (gcc or clang)
HAS_CC:
sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"'
build:native:
summary: Builds the application natively on Linux
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
- task: common:generate:icons
- task: generate:dotdesktop
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: linux
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
build:docker:
summary: Cross-compiles for Linux using Docker with Zig (for macOS/Windows hosts)
internal: true
deps:
- task: common:build:frontend
- task: common:generate:icons
- task: generate:dotdesktop
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required for cross-compilation to Linux. Please install Docker."
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
msg: |
Docker image '{{.CROSS_IMAGE}}' not found.
Build it first: wails3 task setup:docker
cmds:
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}}
- docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
- mkdir -p {{.BIN_DIR}}
- mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
vars:
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
# Mount Go module cache for faster builds
GO_CACHE_MOUNT:
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
# Extract replace directives from go.mod and create -v mounts for each
REPLACE_MOUNTS:
sh: |
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
# Convert relative paths to absolute
if [ "${path#/}" = "$path" ]; then
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
fi
# Only mount if directory exists
if [ -d "$path" ]; then
echo "-v $path:$path:ro"
fi
done | tr '\n' ' '
package:
summary: Packages the application for Linux
deps:
- task: build
cmds:
- task: create:appimage
- task: create:deb
- task: create:rpm
- task: create:aur
create:appimage:
summary: Creates an AppImage
dir: build/linux/appimage
deps:
- task: build
- task: generate:dotdesktop
cmds:
- cp "{{.APP_BINARY}}" "{{.APP_NAME}}"
- cp ../../appicon.png "{{.APP_NAME}}.png"
- wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
vars:
APP_NAME: '{{.APP_NAME}}'
APP_BINARY: '../../../bin/{{.APP_NAME}}'
ICON: '{{.APP_NAME}}.png'
DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
OUTPUT_DIR: '../../../bin'
create:deb:
summary: Creates a deb package
deps:
- task: build
cmds:
- task: generate:dotdesktop
- task: generate:deb
create:rpm:
summary: Creates a rpm package
deps:
- task: build
cmds:
- task: generate:dotdesktop
- task: generate:rpm
create:aur:
summary: Creates a arch linux packager package
deps:
- task: build
cmds:
- task: generate:dotdesktop
- task: generate:aur
generate:deb:
summary: Creates a deb package
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:rpm:
summary: Creates a rpm package
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:aur:
summary: Creates a arch linux packager package
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:dotdesktop:
summary: Generates a `.desktop` file
dir: build
cmds:
- mkdir -p {{.ROOT_DIR}}/build/linux/appimage
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}"
vars:
APP_NAME: '{{.APP_NAME}}'
EXEC: '{{.APP_NAME}}'
ICON: '{{.APP_NAME}}'
CATEGORIES: 'Development;'
OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'
sign:deb:
summary: Signs the DEB package
desc: |
Signs the .deb package with a PGP key.
Configure PGP_KEY in the vars section at the top of this file.
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: create:deb
cmds:
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" --pgp-key {{.PGP_KEY}} {{if .SIGN_ROLE}}--role {{.SIGN_ROLE}}{{end}}
preconditions:
- sh: '[ -n "{{.PGP_KEY}}" ]'
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
sign:rpm:
summary: Signs the RPM package
desc: |
Signs the .rpm package with a PGP key.
Configure PGP_KEY in the vars section at the top of this file.
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: create:rpm
cmds:
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" --pgp-key {{.PGP_KEY}}
preconditions:
- sh: '[ -n "{{.PGP_KEY}}" ]'
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
sign:packages:
summary: Signs all Linux packages (DEB and RPM)
desc: |
Signs both .deb and .rpm packages with a PGP key.
Configure PGP_KEY in the vars section at the top of this file.
Password is retrieved from system keychain (run: wails3 setup signing)
cmds:
- task: sign:deb
- task: sign:rpm
preconditions:
- sh: '[ -n "{{.PGP_KEY}}" ]'
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"

View File

@ -1,35 +0,0 @@
#!/usr/bin/env bash
# Copyright (c) 2018-Present Lea Anthony
# SPDX-License-Identifier: MIT
# Fail script on any error
set -euxo pipefail
# Define variables
APP_DIR="${APP_NAME}.AppDir"
# Create AppDir structure
mkdir -p "${APP_DIR}/usr/bin"
cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/"
cp "${ICON_PATH}" "${APP_DIR}/"
cp "${DESKTOP_FILE}" "${APP_DIR}/"
if [[ $(uname -m) == *x86_64* ]]; then
# Download linuxdeploy and make it executable
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
chmod +x linuxdeploy-x86_64.AppImage
# Run linuxdeploy to bundle the application
./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage
else
# Download linuxdeploy and make it executable (arm64)
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage
chmod +x linuxdeploy-aarch64.AppImage
# Run linuxdeploy to bundle the application (arm64)
./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage
fi
# Rename the generated AppImage
mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage"

View File

@ -1,13 +0,0 @@
[Desktop Entry]
Version=1.0
Name=My Product
Comment=A VideoConcat application
# The Exec line includes %u to pass the URL to the application
Exec=/usr/local/bin/videoconcat %u
Terminal=false
Type=Application
Icon=videoconcat
Categories=Utility;
StartupWMClass=videoconcat

View File

@ -1,67 +0,0 @@
# Feel free to remove those if you don't want/need to use them.
# Make sure to check the documentation at https://nfpm.goreleaser.com
#
# The lines below are called `modelines`. See `:help modeline`
name: "videoconcat"
arch: ${GOARCH}
platform: "linux"
version: "0.1.0"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "A VideoConcat application"
vendor: "My Company"
homepage: "https://wails.io"
license: "MIT"
release: "1"
contents:
- src: "./bin/videoconcat"
dst: "/usr/local/bin/videoconcat"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/videoconcat.png"
- src: "./build/linux/videoconcat.desktop"
dst: "/usr/share/applications/videoconcat.desktop"
# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
depends:
- libgtk-3-0
- libwebkit2gtk-4.1-0
# Distribution-specific overrides for different package formats and WebKit versions
overrides:
# RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
rpm:
depends:
- gtk3
- webkit2gtk4.1
# Arch Linux packages (WebKit 4.1)
archlinux:
depends:
- gtk3
- webkit2gtk-4.1
# scripts section to ensure desktop database is updated after install
scripts:
postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
# You can also add preremove, postremove if needed
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
# replaces:
# - foobar
# provides:
# - bar
# depends:
# - gtk3
# - libwebkit2gtk
# recommends:
# - whatever
# suggests:
# - something-else
# conflicts:
# - not-foo
# - not-bar
# changelog: "changelog.yaml"

View File

@ -1,21 +0,0 @@
#!/bin/sh
# Update desktop database for .desktop file changes
# This makes the application appear in application menus and registers its capabilities.
if command -v update-desktop-database >/dev/null 2>&1; then
echo "Updating desktop database..."
update-desktop-database -q /usr/share/applications
else
echo "Warning: update-desktop-database command not found. Desktop file may not be immediately recognized." >&2
fi
# Update MIME database for custom URL schemes (x-scheme-handler)
# This ensures the system knows how to handle your custom protocols.
if command -v update-mime-database >/dev/null 2>&1; then
echo "Updating MIME database..."
update-mime-database -n /usr/share/mime
else
echo "Warning: update-mime-database command not found. Custom URL schemes may not be immediately recognized." >&2
fi
exit 0

View File

@ -1 +0,0 @@
#!/bin/bash

View File

@ -1 +0,0 @@
#!/bin/bash

View File

@ -1 +0,0 @@
#!/bin/bash

View File

@ -1,183 +0,0 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
# Signing configuration - edit these values for your project
# SIGN_CERTIFICATE: "path/to/certificate.pfx"
# SIGN_THUMBPRINT: "certificate-thumbprint" # Alternative to SIGN_CERTIFICATE
# TIMESTAMP_SERVER: "http://timestamp.digicert.com"
#
# Password is stored securely in system keychain. Run: wails3 setup signing
# Docker image for cross-compilation with CGO (used when CGO_ENABLED=1 on non-Windows)
CROSS_IMAGE: wails-cross
tasks:
build:
summary: Builds the application for Windows
cmds:
# Auto-detect CGO: if CGO_ENABLED=1, use Docker; otherwise use native Go cross-compile
- task: '{{if and (ne OS "windows") (eq .CGO_ENABLED "1")}}build:docker{{else}}build:native{{end}}'
vars:
ARCH: '{{.ARCH}}'
DEV: '{{.DEV}}'
vars:
# Default to CGO_ENABLED=0 if not explicitly set
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
build:native:
summary: Builds the application using native Go cross-compilation
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
- task: common:generate:icons
cmds:
- task: generate:syso
- go build {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
- cmd: powershell Remove-item *.syso
platforms: [windows]
- cmd: rm -f *.syso
platforms: [linux, darwin]
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{end}}'
env:
GOOS: windows
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
GOARCH: '{{.ARCH | default ARCH}}'
build:docker:
summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows)
internal: true
deps:
- task: common:build:frontend
- task: common:generate:icons
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required for CGO cross-compilation. Please install Docker."
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
msg: |
Docker image '{{.CROSS_IMAGE}}' not found.
Build it first: wails3 task setup:docker
cmds:
- task: generate:syso
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{.CROSS_IMAGE}} windows {{.DOCKER_ARCH}}
- docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
- rm -f *.syso
vars:
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
# Mount Go module cache for faster builds
GO_CACHE_MOUNT:
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
# Extract replace directives from go.mod and create -v mounts for each
REPLACE_MOUNTS:
sh: |
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
# Convert relative paths to absolute
if [ "${path#/}" = "$path" ]; then
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
fi
# Only mount if directory exists
if [ -d "$path" ]; then
echo "-v $path:$path:ro"
fi
done | tr '\n' ' '
package:
summary: Packages the application
cmds:
- task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}'
vars:
FORMAT: '{{.FORMAT | default "nsis"}}'
generate:syso:
summary: Generates Windows `.syso` file
dir: build
cmds:
- wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
vars:
ARCH: '{{.ARCH | default ARCH}}'
create:nsis:installer:
summary: Creates an NSIS installer
dir: build/windows/nsis
deps:
- task: build
cmds:
# Create the Microsoft WebView2 bootstrapper if it doesn't exist
- wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
- |
{{if eq OS "windows"}}
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
{{else}}
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
create:msix:package:
summary: Creates an MSIX package
deps:
- task: build
cmds:
- |-
wails3 tool msix \
--config "{{.ROOT_DIR}}/wails.json" \
--name "{{.APP_NAME}}" \
--executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
--arch "{{.ARCH}}" \
--out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
{{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
{{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
{{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
CERT_PATH: '{{.CERT_PATH | default ""}}'
PUBLISHER: '{{.PUBLISHER | default ""}}'
USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
install:msix:tools:
summary: Installs tools required for MSIX packaging
cmds:
- wails3 tool msix-install-tools
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}.exe'
sign:
summary: Signs the Windows executable
desc: |
Signs the .exe with an Authenticode certificate.
Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file.
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: build
cmds:
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}}
preconditions:
- sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]'
msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml"
sign:installer:
summary: Signs the NSIS installer
desc: |
Creates and signs the NSIS installer.
Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file.
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: create:nsis:installer
cmds:
- wails3 tool sign --input "build/windows/nsis/{{.APP_NAME}}-installer.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}}
preconditions:
- sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]'
msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

@ -1,15 +0,0 @@
{
"fixed": {
"file_version": "0.1.0"
},
"info": {
"0000": {
"ProductVersion": "0.1.0",
"CompanyName": "My Company",
"FileDescription": "A VideoConcat application",
"LegalCopyright": "© 2026, My Company",
"ProductName": "My Product",
"Comments": "This is a comment"
}
}
}

View File

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
IgnorableNamespaces="uap3">
<Identity
Name="com.example.videoconcat"
Publisher="CN=My Company"
Version="0.1.0.0"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>My Product</DisplayName>
<PublisherDisplayName>My Company</PublisherDisplayName>
<Description>A VideoConcat application</Description>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="en-us" />
</Resources>
<Applications>
<Application Id="com.example.videoconcat" Executable="videoconcat" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="My Product"
Description="A VideoConcat application"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
<Extensions>
<desktop:Extension Category="windows.fullTrustProcess" Executable="videoconcat" />
</Extensions>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@ -1,54 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<MsixPackagingToolTemplate
xmlns="http://schemas.microsoft.com/msix/packaging/msixpackagingtool/template/2022">
<Settings
AllowTelemetry="false"
ApplyACLsToPackageFiles="true"
GenerateCommandLineFile="true"
AllowPromptForPassword="false">
</Settings>
<Installer
Path="videoconcat"
Arguments=""
InstallLocation="C:\Program Files\My Company\My Product">
</Installer>
<PackageInformation
PackageName="My Product"
PackageDisplayName="My Product"
PublisherName="CN=My Company"
PublisherDisplayName="My Company"
Version="0.1.0.0"
PackageDescription="A VideoConcat application">
<Capabilities>
<Capability Name="runFullTrust" />
</Capabilities>
<Applications>
<Application
Id="com.example.videoconcat"
Description="A VideoConcat application"
DisplayName="My Product"
ExecutableName="videoconcat"
EntryPoint="Windows.FullTrustApplication">
</Application>
</Applications>
<Resources>
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Properties>
<Framework>false</Framework>
<DisplayName>My Product</DisplayName>
<PublisherDisplayName>My Company</PublisherDisplayName>
<Description>A VideoConcat application</Description>
<Logo>Assets\AppIcon.png</Logo>
</Properties>
</PackageInformation>
<SaveLocation PackagePath="videoconcat.msix" />
<PackageIntegrity>
<CertificatePath></CertificatePath>
</PackageIntegrity>
</MsixPackagingToolTemplate>

View File

@ -1,114 +0,0 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "my-project" # Default "VideoConcat"
## !define INFO_COMPANYNAME "My Company" # Default "My Company"
## !define INFO_PRODUCTNAME "My Product Name" # Default "My Product"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0"
## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© 2026, My Company"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd

View File

@ -1,236 +0,0 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "VideoConcat"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "My Company"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "My Product"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.1.0"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2026, My Company"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
!macroend
!macro wails.unassociateFiles
; Delete app associations
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
!macroend

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.example.videoconcat" version="0.1.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>

View File

@ -1,8 +0,0 @@
{
"super_users": [
{
"username": "super",
"password_hash": "将此处替换为密码的MD5 hash值32位十六进制字符串。密码'080500'的MD5 hash值会在程序启动时输出到日志中"
}
]
}

View File

@ -1,14 +0,0 @@
//go:build darwin
package main
import (
"embed"
)
//go:embed resources/ffmpeg/darwin/*
var embeddedFFmpeg embed.FS
func getEmbeddedFFmpeg() embed.FS {
return embeddedFFmpeg
}

View File

@ -1,14 +0,0 @@
//go:build !darwin && !windows && !linux
package main
import (
"embed"
)
//go:embed resources/ffmpeg
var embeddedFFmpeg embed.FS
func getEmbeddedFFmpeg() embed.FS {
return embeddedFFmpeg
}

View File

@ -1,14 +0,0 @@
//go:build linux
package main
import (
"embed"
)
//go:embed resources/ffmpeg/linux/*
var embeddedFFmpeg embed.FS
func getEmbeddedFFmpeg() embed.FS {
return embeddedFFmpeg
}

View File

@ -1,14 +0,0 @@
//go:build windows
package main
import (
"embed"
)
//go:embed resources/ffmpeg/windows/*
var embeddedFFmpeg embed.FS
func getEmbeddedFFmpeg() embed.FS {
return embeddedFFmpeg
}

View File

@ -1,9 +0,0 @@
//@ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
Object.freeze($Create.Events);

View File

@ -1,2 +0,0 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

Some files were not shown because too many files have changed in this diff Show More