通软电子班牌 GarnitureControl 与 VisualBlock 原理详解

一、关于 GarnitureControl

1. GarnitureControl 的加载与获取

查看 GS.Terminal.GarnitureControl.ControlFinder,注意到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ControlFinder : IDisposable
{
[ImportMany(typeof(IControl))]
public IControl[] Views;
// Token: 0x06000005 RID: 5 RVA: 0x000020DC File Offset: 0x000002DC
public bool Find()
{
try
{
AggregateCatalog aggregateCatalog = new AggregateCatalog();
DirectoryCatalog item = new DirectoryCatalog(AddonActivator.AddonContext.Addon.Location + "\\Controls", "*.dll");
aggregateCatalog.Catalogs.Add(item);
new CompositionContainer(aggregateCatalog, new ExportProvider[0]).ComposeParts(new object[]
{
this
});
return true;
}
catch (Exception errorException)
{
AddonActivator.AddonContext.Logger.Error("查找Control出错", errorException);
}
return false;
}
}

基本操作,没什么好说的,使用 System.ComponentModel.Composition 反射动态地加载程序集中指定类型(实现了 IControl 接口)的类罢了。

看到服务类。注意,这一段比较重要!虽然说只是获取一个 GarnitureControl 对象,但是涉及到一个 Action,其他地方可能用到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
namespace GS.Terminal.GarnitureControl
{
// Token: 0x02000005 RID: 5
public class Service
{
// Token: 0x06000012 RID: 18 RVA: 0x0000225C File Offset: 0x0000045C

// 两个参数。一个键,另一个是返回的 Action
public UserControl FindControlByKey(string key, ref Action<object> handle)
{
GarnitureControl garnitureControl = GarnitureControl.Controls.FirstOrDefault((GarnitureControl ss) => ss.ControlKey == key);
// 根据名称获取
if (garnitureControl == null)
{
return null;
}
UserControl controlEntity = garnitureControl.ControlEntity;
IControl @object = (IControl)controlEntity;
// 强制类型转换为 IControl。因为 @object.setData() 方法实现自 IControl.setData()
handle = new Action<object>(@object.setData);
// 这样,在别处就可以通过 handle(args) 调用 @object.setData() 了
return controlEntity;
}
}
}

2. GarnitureControl 的有关操作

关于如何添加 GarnitureControl 不是这里讨论的重点。这里主要介绍如何操纵已经存在的 GarnitureControl

以跑马灯为例。看到 /GS.Terminal.GarnitureControl/Controls/CommonControls.dll - CommonControls.BannerMessage。我们主要关心它的 setData() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
[ControlInfo("跑马灯信息展示", "BannerMessage")]
// ControlInfo 属性中 BannerMessage 就是 FindControlByKey() 的参数 key
public class BannerMessage : UserControl, IControl, IComponentConnector
{
public void setData(object data)
{
bool flag = data != null;
if (flag)
{
bool flag2 = data.ToString().StartsWith("Command.Add");
// 如果发送的 data 以 Command.Add 开头……
if (flag2)
{
this.MsgList.Add(data.ToString().Substring(11));
// ……则添加 Command.Add 后面的文本到跑马灯列表
}
bool flag3 = data.ToString().StartsWith("Command.Remove");
// 同理
if (flag3)
{
this.MsgList.Remove(data.ToString().Substring(14));
}
bool flag4 = this.MsgList.Count == 1;
// 如果有跑马灯列表里有文本……
if (flag4)
{
// ……则播放
this.canvas1.Visibility = Visibility.Visible;
this.PlayIndex = 0;
this.CeaterAnimation(this.msg);
}
bool flag5 = this.MsgList.Count == 0;
if (flag5)
{
// ……否则隐藏跑马灯组件
this.canvas1.Visibility = Visibility.Collapsed;
this.msg.Text = "";
}
}
}
}

再看看 GS.Terminal.SmartBoard.Logic.Garitures 中如何初始化 BannerMessage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
internal static void InitBannerMessageControl()
{
double width;
double left;
double top;
if (SystemParameters.PrimaryScreenWidth == 1080.0)
{
width = 1020.0;
left = 30.0;
top = 1598.0;
}
else
{
width = 1700.0;
left = 110.0;
top = 840.0;
}
// 设置位置,无需多言
GarnitureControl garnitureControl = new GarnitureControl();
Action<object> controlHandle = null;
// 实例化
UserControl userControl = Utilites.FindControlByKey("BannerMessage", ref controlHandle);
// 通过 Utilities 中对 GS.Terminal.GarnitureControl.Service.FindControlByKey() 方法的封装,查询名为 BannerMessage 的 GarnitureControl
garnitureControl.ControlHandle = controlHandle;
// 设置其 ControlHandle,相当于 setData()
userControl.Width = width;
garnitureControl.ControlEntity = userControl;
garnitureControl.ID = Utilites.AddGarnitureControl(userControl, top, left);
// 添加
garnitureControl.Key = "BannerMessage";
GaritureCore.GarnitureControlList.Add(garnitureControl);
Program.TerminalStateManagement.StateChanged += BannerMessageControl.TerminalStateManagement_StateChanged;
}

这时候,看到 GS.Terminal.SmartBoard.Logic.Garitures.BannerMessageControl,它实际上是对 CommonControls.BannerMessage.setData() 的一系列封装。

以添加跑马灯消息为例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
internal static void AddBannerMsg(string Msg)
{
if (BannerMessageControl.banner == null)
{
BannerMessageControl.banner = GaritureCore.GarnitureControlList.FirstOrDefault((GarnitureControl ss) => ss.Key == "BannerMessage");
}
if (BannerMessageControl.banner != null)
{
Program.AddonContext.Logger.Debug("AddBannerMsg", null);
DispatcherHelper.CheckBeginInvokeOnUI(delegate
{
BannerMessageControl.banner.ControlHandle("Command.Add" + Msg);
// 和 setData() 完全对应
// 相当于 BannerMessage.setData("Command.Add" + Msg)
});
}
}

同时,这里再提出一种不需要修改班牌 dll 的自定义跑马灯的方法。

可以通过 GarnitureControl 插件服务类中的 FindControlByKey() 获取 ControlHandleAction<object> 实例的引用(注:这时候也会返回一个 UserControl 类型的实例的引用,但是那个实例的引用是无法操纵的,这涉及到 UI 线程安全等等,不是讨论的重点),然后就可以控制 GarnitureControl 的行为了。

二、关于 VisualBlock

0. 如何打开 localData.db

要理解以下代码,需要查看 localData.db 的内容。
考虑使用 SqliteStudio,加密算法 System.Data.Sqlite: RC4,密码 123

1. VisualBlock 的加载与初始化

查看 GS.Terminal.SmartBoard.Logic.VisualBlockCore.Startup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// 此方法由于反编译出现了一定程度的混乱,但是不影响理解。
internal void LoadVisualTemplate()
{
try
{
using (Session session = Program.ObjectSpace.GetSession(ChannelMode.Multiton))
// 准备进行数据库操作
// 下面,以 Local_ 开头的 VisualPublish,VisualTemplate 等为数据库数据类型,
// 不含此开头的为用于 UI 显示的,根据数据库数据类型创建的实例
{
this.Templates.Clear();
this._ThemeName = string.Empty;
object dblocker = this._dblocker;
// 线程锁,防止多线程读写数据库时发生冲突
lock (dblocker)
{
Local_Visual_Publish local_Visual_Publish = session.Query<Local_Visual_Publish>()
.FirstOrDefault((Local_Visual_Publish ss) => ss.TerminalID == Program.MachineId);
// 在数据库的 Local_Visual_Publish 表中,查询 TerminalID 键为此机器的 MachineID 的项
// 相当于 select * from Local_Visual_Publish where TerminalID = ...
// Visual_Publish 是一些基础的设置,比如默认的主题

if (local_Visual_Publish != null)
{
this._ThemeName = local_Visual_Publish.ThemeName;
// 将班牌的主题设为查询到的 Local_Visual_Publish 的 ThemeName 键的值

List<Local_VisualTemplate> templates = (from ss in local_Visual_Publish.Templates
orderby ss.SortIndex
select ss).ToList<Local_VisualTemplate>();
// 这里需要指出两点:
// 1. 为什么没有再单独查询 VisualTemplate?因为查询 VisualPublish 时,
// 已经通过 GS.Unitive.Framework.Data.Xpo 相关内容自动获取了
// 其 Publish 值应当与 MachineID 对应,参见本地数据库 localData.db 内容
// 2. orderby 只是一个查询用的子句,这里进行了一个根据 SortIndex 从低到高的排序

int num = 1;
Func<Local_VisualBlock, VisualBlockItem> <>9__2;
foreach (Local_VisualTemplate local_VisualTemplate in templates)
{
// 遍历刚刚查询到的 Local_VisualTemplate

List<BlockTemplate> templates2 = this.Templates;
// 用来存储构造完毕的 BlockTemplate 的列表
// 一个 BlockTemplate 相当于是一个滑动页面

BlockTemplate blockTemplate = new BlockTemplate();
// 创建新的 BlockTemplate 实例

blockTemplate.TemplateName = local_VisualTemplate.TemplateName;
// 模板内部名称

blockTemplate.DisplayName = local_VisualTemplate.DisplayName;
// 用于显示的名称(上方导航栏中显示,参见 GS.Terminal.SmartBoard.Logic.Garitures.ViewTabBarControl)

blockTemplate.Index = num++;
// 索引

blockTemplate.TemplateType = BlockTemplateType.Theme;
// 主题

IEnumerable<Local_VisualBlock> source = local_VisualTemplate.VisualBlocks.ToList<Local_VisualBlock>();
// 获得该 VisualTemplate 下所有的 Local_VisualBlock 项目,准备遍历
// 注意,这里获取到的 Local_VisualBlock 的 Template 键的值应该与 Local_VisualTemplate 的 TemplateID 对应
// 参见数据库内容

Func<Local_VisualBlock, VisualBlockItem> selector;
// 选择器,根据 Local_VisualBlock 创建相应的 VisualBlockItem

if ((selector = <>9__2) == null)
{
selector = (<>9__2 = delegate(Local_VisualBlock b)
{
BaseBlock blockEntity = null;
DispatcherHelper.RunAsync(delegate
{
blockEntity = this.GetBlock(b.BlockTypeName);
// 获取相应的 Block
// 这里是一个对 GS.Terminal.VisualBlock.Service.GetBlock(string key) 的封装
// 获取 /GS.Terminal.VisualBlock/Bundles/ 下 .dll 中
// 含有属性 [ExportMetadata("Name", <key>)] ,且继承了 BaseBlock 类的各种 Block

blockEntity.DataSource = (b.DataSource.StartsWith("http") ? b.DataSource : (Program.WebPath + "/" + b.DataSource));
blockEntity.Init(Program.AddonContext);
// 初始化 Block,设置远程数据 api 等
}).Wait();
if (!string.IsNullOrEmpty(b.NavTemplateName))
{
blockEntity.NavPageIndex = templates.FindIndex((Local_VisualTemplate ss) => ss.TemplateName == b.NavTemplateName) + 1;
}
return new VisualBlockItem
{
Id = Guid.Parse(b.BlockID),
// ID

// 从这里开始的几项都可以在数据库的 Local_VisualBlock 表中找到
BlockComponent = b.BlockComponent,
// block 的内容,相当于一个描述,没有实际作用

BlockTypeName = b.BlockTypeName,
// block 类型名称
DataSource = b.DataSource,
// 远程 api
NavTemplateName = b.NavTemplateName,
// 导航栏名称等等

Height = b.Height,
Width = b.Width,
X = b.X,
Y = b.Y,
// 位置
// 数据库中包含的键值结束
DataContext = blockEntity
// block 的内容
};
});
}
blockTemplate.Blocks = source.Select(selector).ToList<VisualBlockItem>();
// 将生成了的 Block 全部添加到 blockTemplate
templates2.Add(blockTemplate);
// 添加生成了的 blockTemplate 到所有 BlockTemplate 的列表
}
}
}
DataUpdate.Instance.RebuildUpdateList();
// 重新生成数据刷新列表
session.Disconnect();
// 断开数据库连接
}
}
catch (Exception errorException)
{
Program.AddonContext.Logger.Error("加载主题模版异常", errorException);
}
}

2. VisualBlock 如何获取数据?

在刚刚的加载过程中,RebuildUpdateList() 方法创建了一个列表,其内容为依据实例化了的各种 VisualBlock 创建的一系列 UpdateBlock,用于管理 VisualBlock 数据的获取和更新。

不过注意,UpdateBlock 类只是描述了一个数据结构,实际的逻辑并不在此处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class UpdateBlock
{
public UpdateBlock(VisualBlockItem item)
{
this.BlockEntity = (IUpdate)item.DataContext;
// 相当于 Block 的内容
// 这里强制类型转换为 IUpdate 是为了在后面使用这个 Block 实现的 IUpdate 接口的 LoadLocalData() 方法
this.BlockId = item.Id;
this.TypeName = item.BlockTypeName;
this.WebRequester = new VisualBlockWebRequest(new Uri(Program.localSetting.GlobalConfig.WebPath + "/" + item.DataSource),
string.Format("{0}_{1}.json", item.BlockTypeName, item.Id));
// 指定了获取数据的 api 和数据存储路径
// 班牌目录下 /cache/BlockCache/ 下的 json 文件就是这么来的
}
...
}

下面展示的两个方法控制 Block 数据的更新。其中,UpdateAllBlock() 方法会在 GS.Terminal.SmartBoard.Logic 插件启动时被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// GS.Terminal.SmartBoard.Logic.VisualBlockCore.DataUpdate

// Token: 0x06000256 RID: 598 RVA: 0x0000BB54 File Offset: 0x00009D54
internal void UpdateAllBlock()
{
foreach (string typeName in this._updateBlocks.Keys)
{
this.UpdateBlockDataByTypeName(typeName);
}
}

// Token: 0x06000257 RID: 599 RVA: 0x0000BBAC File Offset: 0x00009DAC
internal void UpdateBlockDataByTypeName(string typeName)
{
List<UpdateBlock> list;
if (this._updateBlocks.TryGetValue(typeName, out list))
{
list.ForEach(delegate(UpdateBlock b)
{
// 遍历 UpdateBlock
if (!b.IsBusy)
{
try
{
b.IsBusy = true;
if (b.WebRequester.UpdateData() == VisualBlockUpdateResult.Update)
// 先从远程 api 获取数据到本地的 json 文件
{
b.BlockEntity.LoadLocalData(b.WebRequester.CacheFile);
// 调用该 Block 实现的 IUpdate 的 LoadLocalData() 方法
// 注意!类似的写法可以用于人为修改 Block 的内容!
// 比如在“班级风采”播放自定义视频等等

// 此外,每个 VisualBlock 的 json 数据结构都各有差别,但基本都包含一个 result 节点
// 有些 VisualBlock 从服务器获取到的 json 数据结构的 result 节点的内容是经过 gzip 压缩的
// 比如 RichNotice 等
}
}
finally
{
b.IsBusy = false;
}
}
});
}
}

这些逻辑就是实现班牌滑动页面上控件更新的全部了。

仿照这样的逻辑,我们可以写一个人为创建自定义 VisualTemplate 的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public void RewriteVisualTemplate(string template_name, string display_name, int template_overwrite_index, List<VisualBlockItem> items)
{
BlockTemplate blockTemplate = new BlockTemplate();
blockTemplate.TemplateName = template_name;
blockTemplate.DisplayName = display_name;
blockTemplate.Index = template_overwrite_index;
blockTemplate.TemplateType = BlockTemplateType.Theme;
// enum BlockTemplateType.Theme = 0x0

blockTemplate.Blocks = items;
// 这里需要指出的是,其实 BlockTemplate 的 List<VisualBlockItem> Blocks 没有在 BlockTemplate 的构造函数中实例化,需要手动实例化
// 此外,一个 BlockTemplate 可能包含多个 VisualBlockItem,比如首页

blockTemplate.Previous = Utilites.ViewModelLocator.MainPage.TemplateList[template_overwrite_index - 1];
blockTemplate.Next = Utilites.ViewModelLocator.MainPage.TemplateList[template_overwrite_index + 1];

// 滑动页面的切换实际上用的是一个类似链表的结构,所以可以这样替换
DispatcherHelper.RunAsync(
() =>
{
// DispatcherHelper 为 Mvvm 的内容
// (GS.Terminal.SmartBoard.Logic.Core.)Utilites 为 GS 的杂项工具
Utilites.ViewModelLocator.MainPage.TemplateList[template_overwrite_index] = blockTemplate;
// 执行替换
}
);
}

// 创建 VisualBlockItem
// 参数与 localData.db 中的键对应
public static VisualBlockItem GetBlock(string block_name, string json_filename, string component, int width, int height, int x, int y, string data_source = "", string nav_template_name = "")
{

IBlockService firstOrDefaultService = Program.AddonContext. GetFirstOrDefaultService<IBlockService>("GS.Terminal.VisualBlock");
// 获取 VisualBlock 有关服务
BaseBlock block = firstOrDefaultService.GetBlock(block_name);
block.Init(Program.AddonContext);

IUpdate update = (IUpdate)block;
update.LoadLocalData(media_json_filename);


return new VisualBlockItem
{
Id = Guid.NewGuid(),
BlockComponent = component,
BlockTypeName = ((IBlock)block).TypeName,
DataSource = data_source,
NavTemplateName = nav_template_name,
Width = width,
Height = height,
X = x,
Y = y,
DataContext = (BaseBlock)update
}
}

3. 一种可能的不需要修改 dll 的添加自定义 VisualBlock 的方法

另外,这里再提出一种不需要修改 dll 的方法。

使用 GS.Terminal.LogicShellIViewHelperService 或者 GalaSoft.MvvmLightSimpleIoc 获取相关的 ViewModel,再进行操作。

【备忘】关于下一代通软注入破解器的一点迷思

在 2024/1/3 班牌大修事件后,意识到目前的反编译破解方法仍然过于暴力,现在提出一种新的破解思路,以避免各种维修、更新等带来的破解失效或暴露风险。

根本不行,.Net 会没法同时加载一个程序集的两个版本,最后还是得替换文件

一、概论

模仿通软的服务加载逻辑,在运行时添加破解相关服务,以避免直接对通软程序代码进行修改带来的问题。

二、具体实现

  1. 利用 System.Reflection 加载相关注入破解类;
  2. 获得相关插件上下文,添加:
1
2
3
4
5
6
7
8
9
using GS.Unitive.Framework.Core;
using System.Reflection;

Assembly assembly = ...;
Type t = assembly.GetType("...");
object service = Activator.CreateInstance(t);

IAddon addon = AddonRuntime.GetInstalledAddons().FirstOrDefault((IAddon ss) => ss.SymbolicName == "Addon");
addon.Context.AddService(service, t);

或者说,可以通过提前加载破解插件,达到覆盖的效果?

例如:将破解插件的 StartLevel 调至 0

妈的,跟魏东拼了!

二十几个班牌被通软拆走检修了


经过一周的骗钱检修,通软终于把班牌还回来了

可惜并没有人发现(或者装作没有发现)班牌里的神必文件

【备忘】通软“智慧校园”系统修改与自定义技巧

说在前面:本备忘的内容均以电子班牌系统为例进行介绍。

一、插件系统

0. 概论

通软公司的统一开发框架支持且只支持一种基于插件的开发。如此操作似乎是有着解耦合与代码复用方面的考量(例如刷卡组件便是电子班牌与校门口刷卡门岗共用)。

在这种指导思想的引领下,通软的任何一套系统的“主程序”只能被称为是一个插件加载器,而所有的业务逻辑、UI 等等全部被拆散在了一个一个 .Net Framework 类库中。

固然,这种设计模式有着一定的合理性,并的确实现了通软的同志们所希冀的解耦合与复用性,但是也给第三者对其系统的修改带来了极大的便利。实际上,接下来的一切修改,都是基于插件系统进行的。

以下,将介绍通软的插件系统的基本内容。

1. 一套插件的基本组成部分

一般说来,一个插件最少应当包含以下几个组成部分:

  1. 至少一个 .Net Framework .dll 类库;
  2. 一个对插件基本的结构进行描述的 Mapper.xml 文件;
  3. 一个保存着插件配置的 Config.xml 文件。

a. 类库

见后文。

b. Mapper.xml

Mapper.xml 对插件的基本内容进行了描述。以下是一个案例(/GS.Terminal.SmartBoard.Logic/Mapper.xml):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="utf-8" ?>
<Extensibility xmlns="urn:Chinags-Extensibility-1.0" Name="智能班牌逻辑插件" SymbolicName="GS.Terminal.SmartBoard.Logic" Version="3.4.2.62" StartLevel="41">
<License>nX8iVjYNw97wiLmepVhiT...(略)</License>
<Activator Type="GS.Terminal.SmartBoard.Logic.Program"/>
<Runtime>
<Assembly Path="GS.Terminal.SmartBoard.Logic.dll" Share="true"/>
<Assembly Path="GS.Terminal.SmartBoard.LocalDB.dll" Share="false"/>
</Runtime>
<ObjectSpaces>
<Channel ConnectionName="sqlite" ModelAssembly="GS.Terminal.SmartBoard.LocalDB" Name="sqlite"/>
</ObjectSpaces>
<Services>
<Service TypeAndName="GS.Terminal.SmartBoard.Logic.Core.Service" Caption="与服务通讯实时接收推送">
</Service>
<Service TypeAndName="GS.Terminal.SmartBoard.Logic.Core.AdministratorService" Caption="管理员相关服务">
</Service>
</Services>
</Extensibility>

这里就关键的几点进行解释:

  1. NameSymbolicName 字段:前者不重要,后者必须与存储插件的文件夹名称一致。
  2. StartLevel 字段:该字段的值越小,启动越早。建议破解插件在通软规定的“其他插件” StartLevel 段(≥80)启动,以避免潜在的依赖关系问题。
  3. License 字段:插件的证书。后文会对此进行详细解释。此字段欠缺会导致插件被拒绝加载!
  4. Activator 字段:一个实现了 GS.Unitive.Framework.Core.IAddonActivator 接口的类。后文会对此进行详细解释。
  5. Runtime/Assembly 字段:即 .dll 类库的文件名。shared 的真伪决定了能否在别的插件中创建该类库中某个类的实例(不重要)。
  6. ObjectSpaces 字段:实际用途暂不明确,可能与本地数据库读写相关。
  7. Services 字段:将类库中的某些类注册为一系列可以从其他插件中读取并调用其方法的“服务”。服务系统是插件系统中非常关键的一部分。

如果这个文件没有正常配置,插件是无法加载的。

c. Config.xml

Config.xml 相当于插件的配置文件。形如:

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8" ?>
<Settings xmlns="urn:Chinags-Configuration" AddonName="GS.Terminal.Theme">
<Dictionaries>
<Dict Name="Theme" Caption="默认主题">
<Key Caption="主题名称" Choice="" Name="ThemeName" Value="Default"/>
</Dict>
</Dictionaries>
</Settings>
  1. AddonName 字段:与 AddonSymbolicName 一致。
  2. DictionariesKey:相当于是一系列键值对。具体使用后文会解释。

2. 插件中类库的具体结构

尽管在反编译当中会发现通软自己的插件中有着巨大多命名空间和类,但是实际上大部分的内容都是用来实现各种业务逻辑的。一般说来,一个最基本的插件最多只需要包含以下内容:

  1. 一个实现了 GS.Unitive.Framework.Core.IAddonActivator 接口的 激活器 类(名称不一定是这个)。该类应当在 Mapper.xmlActivator 字段中被注册。例如,对于命名空间 MyProject.MyNamespace 下一个类 MyActivator,应当这样写:
1
<Activator Type="MyProject.MyNamespace.MyActivator"/>
  1. Service 服务类。名称可以任取(当然为了可读性方面的考虑建议以 Service 结尾)。其应当在 Mapper.xmlServices 字段中被注册。例如,对于命名空间 MyProject.MyNamespace 下一个类 MyService,应当这样写:
1
2
3
4
5
6
7
<Services>
<Service
TypeAndName="MyProject.MyNamespace.MyService"
Caption="我的服务">
</Service>
<!--其他的 Service 以此类推-->
</Services>

注册后,就可以在别的插件调用 MyService 里面的方法了,详见后文。

3. 插件内部运作与插件间交互的基本操作

主要介绍以下三点:插件的启停、插件数据相关、插件服务的调用。

a. 插件的启停

前面说到,插件应当包含一个实现了 GS.Unitive.Framework.Core.IAddonActivator 接口的激活器类。该类应当实现其 Start(IAddonContext)Stop(IAddonContext) 方法。这两个方法分别会在插件启动/停止时被调用。

比如这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System; // 注:其他 .Net 模块为避免赘余不再写出。

using GS.Unitive.Framework.Core;
using GS.Unitive.Framework.Persistent;

namespace MyProject.MyNamespace
{
public class MyActivator : IAddonActivator
{
public IAddonContext addonContext;

public void Start(IAddonContext context)
{
this.addonContext = context;
this.addonContext.Logger.Info("成功加载插件");
// IAddonContext.Logger 为公用日志工具
}

public void Stop(IAddonContext context)
{
// 停止时执行的内容,如终止 UDP 监听等。
}
}
}

这样,便可以实现基本的插件启停。

b. 插件数据相关

首先介绍对插件配置的读取。

假如我们的插件有这样一个配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="utf-8" ?>
<Settings xmlns="urn:Chinags-Configuration" AddonName="MyProject">
<Dictionaries>
<Dict Name="baseConfig" Caption="基础配置">
<Key
Caption="第一个"
Choice=""
Name="Key1"
Value="Value1"/>
</Dict>
</Dictionaries>
</Settings>

那么,想要获取 baseConfigKey1 的值,便可以这样写:

1
2
// addonContext : IAddonContext
string value1 = addonContext.DictionaryValue("baseConfig", "Key1");

另外,如果没有找到 Key1,则会返回 null

然后介绍创建和读取交互式数据。

交互式数据一经创建,便可由任何插件读取。

1
2
3
4
5
6
7
8
object data = somedata;
addonContext.IntercativeData<object>("DataKey", data);
// 创建。泛型尖括号里面填数据值的类型。
dynamic result = addonContext.IntercativeData("DataKey");
// 读取
object newdata = somedata;
addonContext.IntercativeData<object>("DataKey", newdata);
// 修改。泛型尖括号里面填数据值的类型。

c. 插件服务调用

对于其他插件已经注册了的服务,可以通过以下方法进行调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
dynamic service = this.addonContext.GetFirstOrDefaultService("GS.Terminal.MainShell", "GS.Terminal.MainShell.Services.UIService");
// 第一个参数为 AddonSymbolicName,第二个为 typeof(Service) 的字符串形式(即 Mapper.xml 中 Services/Service.TypeAndName)。

// KnownTypeName service = this.addonContext.GetFirstOrDefaultService<KnownTypeName>(addonSymbolicName);
// 在 Service 类型已知的情况下,也可以这样写。

// service.SomeMethod();
// 比如这里便是用 GS.Terminal.MainShell 的相关方法注册一个管理员指令,输入后会显示一个弹窗:
service.RegistBackgroundCommand("0", new Action(
() =>
{
service.ShowPrompt("Fuck GS!!", 10);
}
));

注意,如果指定的服务不存在,则会返回 null

4. 常用的服务

  • GS.Terminal.TimeLine 任务计划与定时执行相关;
  • GS.Terminal.GarnitureControl 创建浮窗等;
  • GS.Terminal.DeviceManager 管理读卡器等外设;
  • GS.Terminal.MainShell.Services.UIService 管理用户界面;

5. 基本插件案例

通软在很多服务中都大量使用了 dynamic 动态类型。以下将以读卡器使用为例介绍。

a. 读卡器的使用

以下代码在刷卡时会调用 DoSomething(string) 函数,并传入卡号作为参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// IAddonContext addonContext;
dynamic device = addonContext.GetFirstOrDefaultService("GS.Terminal.DeviceManager","GS.Terminal.DeviceManager.Service.DeviceCallControl");
// 获取设备管理服务

deviceMan.RegistCardCallback(
new Action<dynamic>(
(dynamic data) => // 含有一个 dynamic 类型的参数 data
{
string cardid = data.OperaDeviceData.Message;
// 获取 data 的属性
DoSomething(cardid);
}
)
// 创建一个 Action<dynamic> 实例,用一个委托(delegate)实例化
// 这个 Action<dynamic> 中的内容将会在刷卡时时被执行
); // 调用 RegistCardCallback 方法

二、通软插件的反编译修改

0. 概论

尽管服务可以解决相当一部分的问题。但是仍然有一部分功能并没有被写在服务中,无法通过简单的方法进行调用,这时候就需要对通软原有的插件进行反编译修改。

1. 基本流程案例

这里以播放跑马灯进行举例说明。

众所周知,通软在 GS.Terminal.SmartBoard.Logic 中给出了跑马灯的实现(GS.Terminal.SmartBoard.Logic.Garitures.BannerMessageControl)。但是,播放跑马灯的方法,并不能通过某个服务进行调用。因此,有必要通过某种的举措,将播放跑马灯的方法暴露给其他插件。

考虑以下操作:

  1. 使用 dnSpy 在 GS.Terminal.SmartBoard.Logic.Core 下创建一个 MyCustomService 类。
  2. 修改 GS.Terminal.SmartBoard.LogicMapper.xml,注册这个类为一个服务。
  3. 在这个类中添加以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
using GS.Terminal.SmartBoard.Logic.Garitures;
...

namespace GS.Terminal.SmartBoard.Logic.Core
{
public class MyCustomService
{
public void ShowShadowLantern(string msg)
{
BannerMessageControl.AddBannerMsg(msg);
}
}
}
  1. 编译并保存。

然后,就可以在其他插件中通过服务调用 MyCustomService.ShowShadowLantern() 显示跑马灯了。

2. 特殊功能备忘:滑动页面相关

电子班牌的滑动页面是一个非常有趣的东西。比如“班级风采”
一栏,又可以放图片又可以放视频,可以说有很多手脚可以动。

但是,令人意外的是,不像跑马灯和“更多”栏里面的大图片,“班级风采”里面图片视频等的定时展示并非使用 TimeLineTask,而是另一套非常复杂(且非常屎山)的实现。

这种实现方式可以归纳为 VisualPublish - VisualTemplate - VisualBlock 的三级结构(具体实现时还包含了大量的数据库读写)。其中:

  • VisualPublish 规定了电子班牌加载时使用的主题。
  • VisualTemplate 描述滑动界面的结构及属性。
  • VisualBlock 描述滑动页面上的元素的结构及属性。

如果想要修改“班级风采”上的视频,考虑以下实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using GalaSoft.MvvmLight.Threading;
using IVisualBlock;
using SmartBoardViewModels.Models.VisualBlock;

namespace GS.Terminal.SmartBoard.Logic.Core
{
// Token: 0x02000174 RID: 372
public partial class MyCustomService
{
public void AddMultiMediaVisualTemplate(string media_json_filename)
{
IBlockService firstOrDefaultService = Program.AddonContext.GetFirstOrDefaultService<IBlockService>("GS.Terminal.VisualBlock");
// 获取 VisualBlock 有关服务

BlockTemplate blockTemplate = new BlockTemplate();
blockTemplate.TemplateName = "ClassTemplateStyle";
blockTemplate.DisplayName = "校园风采";
blockTemplate.Index = 2;
blockTemplate.TemplateType = BlockTemplateType.Theme;
// 创建“班级风采” BlockTemplate,和 localData.db 中属性完全对应

BaseBlock block = firstOrDefaultService.GetBlock("ClassMultiMedia");
block.Init(Program.AddonContext);
// 获取视频与图片控件的 VisualBlock 并初始化
// 位于 GS.Terminal.VisualBlock/Bundles/VisualBlock.dll

IUpdate update = (IUpdate)block;
update.LoadLocalData(media_json_filename);
// 加载指定 json 文件中的数据
// 具体的 json 文件结构可以通过抓包或者模仿 cache/BlockCache/<((IBlock)block).TypeName>_<GUID>.json查看

// 这里必须强制类型转换为 IUpdate 类型,因为 VisualBlock 的该方法继承自 IUpdate 接口

blockTemplate.Blocks = new List<VisualBlockItem>();
blockTemplate.Blocks.Add(new VisualBlockItem
{
Id = Guid.NewGuid(),
BlockComponent = "班级风采",
BlockTypeName = ((IBlock)block).TypeName,
DataSource = "Services/SmartBoard/BlockClassMultiMedia/json",
Height = 10,
NavTemplateName = "",
Width = 20,
X = 1,
Y = 1,
// 某些时候,还有一个 int Template 属性
// 上方属性均与 localData.db 中一致
DataContext = (BaseBlock)update
// 再进行一次强制类型转换
});

blockTemplate.Previous = Utilites.ViewModelLocator.MainPage.TemplateList[1];
blockTemplate.Next = Utilites.ViewModelLocator.MainPage.TemplateList[3];
// 链表实现

DispatcherHelper.RunAsync(delegate
{
// DispatcherHelper 为 Mvvm 的内容
// (GS.Terminal.SmartBoard.Logic.Core.)Utilites 为 GS 的杂项工具
Utilites.ViewModelLocator.MainPage.TemplateList[2] = blockTemplate;
// 替换
});
}
}
}

事实上,同样的操作也适用于其他滑动页面,只要把各种属性的值替换掉就行了。
当然,有的页面上可能有多个 VisualBlock(如首页)。

三、杂项

1. 插件签名认证

前面提到,Mapper.xml 中的 License 字段必须正确配置,否则插件无法启动。根据通软的描述,所谓的插件证书只能在“通软统一开发平台”获取,但实际上并不存在这样的一个平台。对此,只能说傻逼通软。

因此,如果想要加载我们自己的插件,必须想办法解决掉这个证书的问题。解决思路可以有以下两种:

  1. 伪造证书;
  2. 绕过证书检测

以下,将分别介绍这两种思路。

a. 伪造证书

通软的证书表面上使用了一个所谓加密狗(SoftDog)组件,实际上只是绕了一个弯从加密狗的 dll 文件的资源文件中获取 RSA 私钥。而通软所谓的认证,也不过只是用这个私钥对插件名称进行了一个签名而已。

因此,只要提取加密狗的资源文件,便可以利用 RSA 签名轻松伪造通软的认证。

考虑以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System;
using System.Security.Cryptography;
...

public string Encrypt(string addonSymbolicName)
{
using (RSACryptoServiceProvider provider = new RSACryptoServiceProvider())
{
string xmlStr = "...";
provider.FromXmlString(xmlStr);
RSAPKCS1SignatureFormatter formatter = new RSAPKCS1SignatureFormatter(provider);
formatter.SetHashAlgorithm("SHA1");

SHA1Managed sha1 = new SHA1Managed();
byte[] rgbHash = sha1.ComputeHash(Encoding.ASCII.GetBytes(addonSymbolicName));

byte[] signature = formatter.CreateSignature(rgbHash);
string b64str = Convert.ToBase64String(signature);

return b64str;
}
}

这样,只要输入一个插件的 AddonSymbolicName,便可以生成对应的认证。

b. 绕过检测

通过对 GS.Unitive.Framework.Core.LicenseValidationVerify() 方法进行修改,使其返回值恒为真,以绕过通软的插件认证检测。

这个方法由于过于暴力,并且已经有更加安全的平替,不建议使用。

2. 关于 localData.db

建议使用 SQLiteStudio 打开,格式 System.Data,密码一般为 123

3. 更新器无效化

为了防止更新导致反编译修改的代码被覆盖,有必要对更新器 AutoUpdate.exe 进行无效化处理。

主要处理 AutoUpdate.MainViewModelDo() 方法。

1
2
3
4
5
6
7
8
9
public void Do()
{
...
if (this.getRemoteFeatureCode(text))
{
LocalLogWriter.Write("无更新");
}
...
}

这样一来,即使有更新,也不会下载更新。

4. 关于编译器生成代码的反推

注意到通软的一些插件反编译后仍然存在大量无法进一步还原的编译器生成代码,阅读起来非常困难。以下介绍一点简单的识别法。

a. 执行动态类型方法

考虑以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// GS.Terminal.SmartBoard.Logic.Core.Startup
public class Startup
{
...
private static void InitManagentWindow() // 通软传统艺能之拼写错误
{
if (Startup.<>o__6.<>p__0 == null)
{
Startup.<>o__6.<>p__0 = CallSite<Action<CallSite, object, string, Action>>.Create(Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "RegistBackgroundCommand", //方法名称在这里
null, typeof(Startup), new CSharpArgumentInfo[]
{
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null),
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.Constant, null),
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType, null)
}));
}
Startup.<>o__6.<>p__0.Target(Startup.<>o__6.<>p__0, Program.AddonContext.GetFirstOrDefaultService("GS.Terminal.MainShell", "GS.Terminal.MainShell.Services.UIService"), // 包含相应方法的 dynamic 类型。在这里是 GetFirstOrDefaultService() 的返回值
"100000", // 参数 1
delegate
{
DoSomething();
} // 参数 2,当然自己写的时候应当在 delegate 外面套一个 Action()
// 以上两个参数与 GS.Terminal.MainShell.Services.UIService.GetFirstOrDefaultService(string, Action) 一一对应。
);
}
...
}

因此,原始代码应当为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Startup
{
...
private static void InitManagentWindow() // 通软传统艺能之拼写错误
{
dynamic service = Program.AddonContext.GetFirstOrDefaultService("GS.Terminal.MainShell", "GS.Terminal.MainShell.Services.UIService");
service.RegistBackgroundCommand("100000", new Action(
() => {
DoSomething();
}
));
}
...
}

b. 获取动态类型的属性

这个在刷卡器反编译出的代码处表现的尤其明显。考虑以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// GS.Terminal.SmartBoard.Logic.Core.LogicCore
internal class LogicCore
{
...
internal static void RegistCardService()
{
object firstOrDefaultService = Program.AddonContext.GetFirstOrDefaultService("GS.Terminal.DeviceManager", "GS.Terminal.DeviceManager.Service.DeviceCallControl");
// 获取了 DeviceCallControl -> dynamic,部分内部实现细节被反编译器隐藏
...
if (target(<>p__, LogicCore.<>o__1.<>p__0.Target(LogicCore.<>o__1.<>p__0, firstOrDefaultService, null)))
{
// 这里似乎是一个判断获取的服务是否是 null
if (LogicCore.<>o__1.<>p__5 == null)
{
LogicCore.<>o__1.<>p__5 = CallSite<Action<CallSite, object, Action<object>>>.Create(Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "RegistCardCallback", null, typeof(LogicCore), new CSharpArgumentInfo[]
{
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null),
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType, null)
}));
// 执行 DeviceCallControl.RegistCardCallback(Action<dynamic>) 方法
}
LogicCore.<>o__1.<>p__5.Target(LogicCore.<>o__1.<>p__5, firstOrDefaultService,

//注意,从这里开始是参数 1 即 Action<dynamic>!
delegate(dynamic data)
{
LogicCore.<>c__DisplayClass1_0 CS$<>8__locals1 = new LogicCore.<>c__DisplayClass1_0();
LogicCore.<>c__DisplayClass1_0 CS$<>8__locals2 = CS$<>8__locals1;
// 创建了一个 LogicCore.<>c__DisplayClass1_0 的实例。
// 在源代码里,这个类应该有别的名字,并且和 LogicCore 在同一个 .cs 文件里
// 根据后面的代码发现,它至少有一个 cardNum 属性

if (LogicCore.<>o__1.<>p__4 == null)
{
LogicCore.<>o__1.<>p__4 = CallSite<Func<CallSite, object, string>>.Create(Binder.Convert(CSharpBinderFlags.None, typeof(string), typeof(LogicCore)));
}
Func<CallSite, object, string> target2 = LogicCore.<>o__1.<>p__4.Target;
CallSite <>p__2 = LogicCore.<>o__1.<>p__4;
// 作用未知

if (LogicCore.<>o__1.<>p__3 == null)
{
LogicCore.<>o__1.<>p__3 = CallSite<Func<CallSite, object, object>>.Create(Binder.GetMember(CSharpBinderFlags.None, "Message", typeof(LogicCore), new CSharpArgumentInfo[]
{
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null)
}));
}
Func<CallSite, object, object> target3 = LogicCore.<>o__1.<>p__3.Target;
CallSite <>p__3 = LogicCore.<>o__1.<>p__3;
// 后获得 data.OperaDeviceData.Message 属性

if (LogicCore.<>o__1.<>p__2 == null)
{
LogicCore.<>o__1.<>p__2 = CallSite<Func<CallSite, object, object>>.Create(Binder.GetMember(CSharpBinderFlags.None, "OperaDeviceData", typeof(LogicCore), new CSharpArgumentInfo[]
{
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null)
}));
}
// 先获得 data.OperaDeviceData 属性

// 注意!属性级别越靠前,它在编译器生成代码中的位置就越靠后!
// 具体细节仍然有必要查看源代码

// 例如在 GS.Terminal.DeviceManager.Service.DeviceCallControl 里,
// RegistCardCallback(Action<dynamic>) 方法实际上是用这个 Action<dynamic>
// 创建了一个事件处理器,其 EventArgs 为 DeviceEventArgs,
// DeviceEventArgs 即包含 OperaDeviceData 属性,
// OperaDeviceData 又有 Message 属性

CS$<>8__locals2.cardNum = target2(<>p__2, target3(<>p__3, LogicCore.<>o__1.<>p__2.Target(LogicCore.<>o__1.<>p__2, data)));
// 将 data.OperaDeviceData 赋给 CS$<>8__locals2.cardNum

Program.AddonContext.Logger.Debug("读卡器读卡结果:" + CS$<>8__locals1.cardNum, null);
}
// 到这里参数 1 结束!
);
}
}
...
}

由此,可以得出,原始代码可能为:

1
2
3
4
5
6
7
8
9
10
11
12
dynamic service = Program.AddonContext.GetFirstOrDefaultService("GS.Terminal.DeviceManager", "GS.Terminal.DeviceManager.Service.DeviceCallControl");
if (service != null)
{
service.RegistCardCallback(new Action<dynamic>(
(dynamic data) => {
SomeClass someclass = new SomeClass();
someclass.cardNum = data.OperaDeviceData.Message;

Program.AddonContext.Logger.Debug(...);
}
));
}

以上。