Steam Workshop Mods

This commit is contained in:
Björn Dahlgren 2021-07-10 15:54:10 +02:00
parent 7b6d0c6425
commit 08beb108a4
25 changed files with 574 additions and 24 deletions

38
app.js
View File

@ -1,4 +1,5 @@
var express = require('express')
var fs = require('fs')
var bodyParser = require('body-parser')
var morgan = require('morgan')
var path = require('path')
@ -11,7 +12,7 @@ var webpackConfig = require('./webpack.config')
var setupBasicAuth = require('./lib/setup-basic-auth')
var Manager = require('./lib/manager')
var Missions = require('./lib/missions')
var Mods = require('./lib/mods')
var SteamMods = require('./lib/steam_mods')
var Logs = require('./lib/logs')
var Settings = require('./lib/settings')
@ -29,17 +30,44 @@ app.use(morgan(config.logFormat || 'dev'))
app.use(serveStatic(path.join(__dirname, 'public')))
/*
Workaround for Steam Workshop with Linux Arma server
Absolute paths are not supported.
Create symlink in Arma folder to Workshop folder.
Rewrite Workshop mods to use relative path to symlinked folder instead.
*/
if (config.type === 'linux' && config.steam && config.steam.path) {
var tempWorkshopFolder = path.join(config.path, 'workshop')
try {
var stat = fs.lstatSync(tempWorkshopFolder)
if (!stat.isSymbolicLink()) {
console.error('Please remove workshop folder from Arma directory manually and restart application')
process.exit(1)
}
fs.unlinkSync(tempWorkshopFolder)
} catch (err) {
if (err.code !== 'ENOENT') {
console.error('Something went wrong when creating workaround for workshop')
console.error(err)
process.exit(1)
}
}
fs.symlinkSync(config.steam.path, tempWorkshopFolder)
}
var logs = new Logs(config)
var manager = new Manager(config, logs)
manager.load()
var missions = new Missions(config)
var mods = new Mods(config)
var mods = new SteamMods(config)
mods.updateMods()
var settings = new Settings(config)
var manager = new Manager(config, logs, mods)
manager.load()
app.use('/api/logs', require('./routes/logs')(logs))
app.use('/api/missions', require('./routes/missions')(missions))
app.use('/api/mods', require('./routes/mods')(mods))

View File

@ -13,6 +13,12 @@ module.exports = {
'@mod1',
'@mod2',
],
steam: {
apiKey: '1234567890ABCDE', // https://steamcommunity.com/dev/apikey
path: 'path-to-main-steam-folder',
username: 'steam_username',
password: 'steam_password',
},
admins: [], // add steam IDs here to enable #login without password
auth: { // If both username and password is set, HTTP Basic Auth will be used. You may use an array to specify more than one user.
username: '', // Username for HTTP Basic Auth

View File

@ -5,9 +5,10 @@ var Server = require('./server')
var filePath = 'servers.json'
var Manager = function (config, logs) {
var Manager = function (config, logs, mods) {
this.config = config
this.logs = logs
this.mods = mods
this.serversArr = []
this.serversHash = {}
}
@ -41,7 +42,7 @@ Manager.prototype.removeServer = function (id) {
}
Manager.prototype._addServer = function (data) {
var server = new Server(this.config, this.logs, data)
var server = new Server(this.config, this.logs, this.mods, data)
this.serversArr.push(server)
this.serversArr.sort(function (a, b) {
return a.title.localeCompare(b.title)

View File

@ -21,6 +21,12 @@ Mods.prototype.delete = function (mod, cb) {
})
}
Mods.prototype.find = function (id) {
this.mods.find(function (mod) {
return mod.id === id
})
}
Mods.prototype.updateMods = function () {
var self = this
glob('**/{@*,csla,gm,vn}/addons', { cwd: self.config.path }, function (err, files) {
@ -28,9 +34,11 @@ Mods.prototype.updateMods = function () {
console.log(err)
} else {
var mods = files.map(function (file) {
var name = path.join(file, '..')
return {
// Find actual parent mod folder from addons folder
name: path.join(file, '..')
id: name,
name: name
}
})

View File

@ -17,9 +17,10 @@ var queryTypes = {
ofpresistance: 'operationflashpoint'
}
var Server = function (config, logs, options) {
var Server = function (config, logs, mods, options) {
this.config = config
this.logs = logs
this.modsManager = mods
this.update(options)
}
@ -95,6 +96,21 @@ Server.prototype.queryStatus = function () {
)
}
Server.prototype.getMods = function () {
var self = this
return this.mods.map(function (mod) {
return self.modsManager.find(mod)
}).filter(function (mod) {
return mod
}).map(function (mod) {
if (self.config.type === 'linux' && self.config.steam && self.config.steam.path) {
return mod.path.replace(self.config.steam.path, 'workshop/')
}
return mod.path
})
}
Server.prototype.getParameters = function () {
var parameters = []
@ -132,6 +148,7 @@ Server.prototype.start = function () {
return this
}
var mods = this.getMods()
var parameters = this.getParameters()
var server = new ArmaServer.Server({
additionalConfigurationOptions: this.getAdditionalConfigurationOptions(),
@ -147,7 +164,7 @@ Server.prototype.start = function () {
hostname: this.createServerTitle(this.title),
localClient: this.number_of_headless_clients > 0 ? ['127.0.0.1'] : null,
missions: this.missions,
mods: this.mods,
mods: mods,
motd: (this.motd && this.motd.split('\n')) || null,
parameters: parameters,
password: this.password,
@ -197,6 +214,7 @@ Server.prototype.startHeadlessClientsIfNeeded = function () {
}
Server.prototype.startHeadlessClients = function () {
var mods = this.getMods()
var parameters = this.getParameters()
var self = this
var headlessClientInstances = _.times(this.number_of_headless_clients, function (i) {
@ -204,7 +222,7 @@ Server.prototype.startHeadlessClients = function () {
filePatching: self.file_patching,
game: self.config.game,
host: '127.0.0.1',
mods: self.mods,
mods: mods,
parameters: parameters,
password: self.password,
path: self.config.path,

96
lib/steam_mods.js Normal file
View File

@ -0,0 +1,96 @@
var events = require('events')
var ArmaSteamWorkshop = require('arma-steam-workshop')
var SteamMods = function (config) {
this.config = config
this.armaSteamWorkshop = new ArmaSteamWorkshop(this.config.steam)
this.mods = []
}
SteamMods.prototype = new events.EventEmitter()
SteamMods.prototype.delete = function (mod, cb) {
var self = this
this.armaSteamWorkshop.deleteMod(mod, function (err) {
if (err) {
console.log(err)
} else {
self.updateMods()
}
if (cb) {
cb(err)
}
})
}
SteamMods.prototype.find = function (id) {
return this.mods.find(function (mod) {
return mod.id === id
})
}
SteamMods.prototype.download = function (workshopId, cb) {
var self = this
this.armaSteamWorkshop.downloadMod(workshopId, function (err) {
self.addStatusForCurrentDowloads()
self.updateMods()
if (cb) {
cb(err)
}
})
self.addStatusForCurrentDowloads()
self.emit('mods', this.mods)
}
SteamMods.prototype.search = function (query, cb) {
this.armaSteamWorkshop.search(query, cb)
}
SteamMods.prototype.updateMods = function () {
var self = this
this.armaSteamWorkshop.mods(function (err, mods) {
if (err) {
console.log(err)
} else {
self.mods = mods
self.addStatusForCurrentDowloads()
self.emit('mods', mods)
}
})
}
SteamMods.prototype.isModDownloading = function (workshopId) {
return this.armaSteamWorkshop.currentDownloads[workshopId] === true
}
SteamMods.prototype.addStatusForCurrentDowloads = function () {
var self = this
this.mods.forEach(function (mod) {
mod.downloading = self.isModDownloading(mod.id)
})
this.addDummyModsForCurrentDowloads()
this.emit('mods', this.mods)
}
SteamMods.prototype.addDummyModsForCurrentDowloads = function () {
var self = this
for (var workshopId in this.armaSteamWorkshop.currentDownloads) {
var mod = self.mods.find(function (mod) {
return mod.id === workshopId
})
if (!mod) {
self.mods.splice(0, 0, {
id: workshopId,
name: workshopId,
path: null,
downloading: true
})
}
}
}
module.exports = SteamMods

View File

@ -18,6 +18,7 @@
},
"dependencies": {
"arma-server": "0.0.10",
"arma-steam-workshop": "https://github.com/Dahlgren/node-arma-steam-workshop/tarball/0396b9941eb43f714bd11df2bc5a99f0b283313e",
"async": "^0.9.0",
"backbone": "1.3.3",
"backbone.bootstrap-modal": "https://github.com/powmedia/backbone.bootstrap-modal/archive/632210077c2424be2ee6ea2aafe0d3fe016ae524.tar.gz",

View File

@ -2,8 +2,11 @@ var Backbone = require('backbone')
module.exports = Backbone.Model.extend({
defaults: {
name: ''
id: '',
downloading: false,
name: '',
path: ''
},
idAttribute: 'name',
idAttribute: 'id',
urlRoot: '/api/mods/'
})

View File

@ -0,0 +1,82 @@
var $ = require('jquery')
var _ = require('underscore')
var Marionette = require('marionette')
var Ladda = require('ladda')
var Mods = require('app/collections/mods')
var tpl = require('tpl/mods/download/form.html')
var sweetAlert = require('sweet-alert')
module.exports = Marionette.ItemView.extend({
events: {
submit: 'beforeSubmit'
},
template: _.template(tpl),
initialize: function (options) {
this.mods = options.mods
this.collection = new Mods()
this.bind('ok', this.submit)
this.bind('shown', this.shown)
var self = this
this.listenTo(this.mods, 'change reset add remove', function () {
self.collection.trigger('reset')
})
},
beforeSubmit: function (e) {
e.preventDefault()
this.submit()
},
shown: function (modal) {
var $okBtn = modal.$el.find('.btn.ok')
$okBtn.addClass('ladda-button').attr('data-style', 'expand-left')
this.laddaBtn = Ladda.create($okBtn.get(0))
this.$el.find('form .query').focus()
},
submit: function (modal) {
var self = this
var $form = this.$el.find('form')
if (modal) {
self.modal.preventClose()
}
$form.find('.form-group').removeClass('has-error')
$form.find('.help-block').text('')
this.laddaBtn.start()
self.modal.$el.find('.btn.cancel').addClass('disabled')
$.ajax({
url: '/api/mods/',
type: 'POST',
data: {
id: $form.find('.query').val()
},
dataType: 'json',
success: function (resp) {
self.laddaBtn.stop()
self.modal.$el.find('.ladda-button').removeClass('disabled')
self.modal.close()
},
error: function (resp) {
self.laddaBtn.stop()
self.modal.$el.find('.ladda-button').removeClass('disabled')
self.modal.close()
sweetAlert({
title: 'Error',
text: 'An error occurred, please consult the logs',
type: 'error'
})
}
})
}
})

View File

@ -1,8 +1,11 @@
var $ = require('jquery')
var _ = require('underscore')
var Marionette = require('marionette')
var BootstrapModal = require('backbone.bootstrap-modal')
var ListItemView = require('app/views/mods/list_item')
var DownloadFormView = require('app/views/mods/download/form')
var SearchFormView = require('app/views/mods/search/form')
var tpl = require('tpl/mods/list.html')
var template = _.template(tpl)
@ -13,7 +16,22 @@ module.exports = Marionette.CompositeView.extend({
template: template,
events: {
'click #refresh': 'refresh'
'click #download': 'download',
'click #refresh': 'refresh',
'click #search': 'search'
},
download: function (event) {
event.preventDefault()
var view = new DownloadFormView({ mods: this.collection })
var modal = new BootstrapModal({
content: view,
animate: true,
cancelText: 'Close',
okText: 'Download'
})
view.modal = modal
modal.open()
},
refresh: function (event) {
@ -28,5 +46,18 @@ module.exports = Marionette.CompositeView.extend({
}
})
},
search: function (event) {
event.preventDefault()
var view = new SearchFormView({ mods: this.collection })
var modal = new BootstrapModal({
content: view,
animate: true,
cancelText: 'Close',
okText: 'Search'
})
view.modal = modal
modal.open()
}
})

View File

@ -14,6 +14,10 @@ module.exports = Marionette.ItemView.extend({
'click .destroy': 'deleteMod'
},
modelEvents: {
change: 'render'
},
deleteMod: function (event) {
var self = this
sweetAlert({

View File

@ -0,0 +1,90 @@
var $ = require('jquery')
var _ = require('underscore')
var Marionette = require('marionette')
var ListItemView = require('app/views/mods/search/list_item')
var Ladda = require('ladda')
var Mods = require('app/collections/mods')
var tpl = require('tpl/mods/search/form.html')
var sweetAlert = require('sweet-alert')
module.exports = Marionette.CompositeView.extend({
events: {
submit: 'beforeSubmit'
},
childView: ListItemView,
childViewContainer: 'tbody',
template: _.template(tpl),
initialize: function (options) {
this.mods = options.mods
this.collection = new Mods()
this.bind('ok', this.submit)
this.bind('shown', this.shown)
var self = this
this.listenTo(this.mods, 'change reset add remove', function () {
self.collection.trigger('reset')
})
},
childViewOptions: function (options) {
options.set('mods', this.mods)
},
beforeSubmit: function (e) {
e.preventDefault()
this.submit()
},
shown: function (modal) {
var $okBtn = modal.$el.find('.btn.ok')
$okBtn.addClass('ladda-button').attr('data-style', 'expand-left')
this.laddaBtn = Ladda.create($okBtn.get(0))
this.$el.find('form .query').focus()
},
submit: function (modal) {
var self = this
var $form = this.$el.find('form')
if (modal) {
self.modal.preventClose()
}
$form.find('.form-group').removeClass('has-error')
$form.find('.help-block').text('')
this.laddaBtn.start()
self.modal.$el.find('.btn.cancel').addClass('disabled')
$.ajax({
url: '/api/mods/search',
type: 'POST',
data: {
query: $form.find('.query').val()
},
dataType: 'json',
success: function (data) {
self.laddaBtn.stop()
self.modal.$el.find('.btn.cancel').removeClass('disabled')
self.collection.set(data)
},
error: function () {
self.laddaBtn.stop()
$form.find('.form-group').addClass('has-error')
$form.find('.help-block').text('Problem searching, try again')
self.modal.$el.find('.btn.cancel').removeClass('disabled')
sweetAlert({
title: 'Error',
text: 'An error occurred, please consult the logs',
type: 'error'
})
}
})
}
})

View File

@ -0,0 +1,52 @@
var $ = require('jquery')
var _ = require('underscore')
var Marionette = require('marionette')
var Ladda = require('ladda')
var tpl = require('tpl/mods/search/list_item.html')
var template = _.template(tpl)
module.exports = Marionette.ItemView.extend({
tagName: 'tr',
template: template,
events: {
'click .install': 'install'
},
templateHelpers: {
downloading: function () {
if (this.mods.get(this.id)) {
return this.mods.get(this.id).get('downloading')
}
return false
}
},
install: function (event) {
var self = this
event.preventDefault()
this.laddaBtn = Ladda.create(this.$el.find('.ladda-button').get(0))
this.laddaBtn.start()
this.$el.find('.ladda-button').addClass('disabled')
$.ajax({
url: '/api/mods/',
type: 'POST',
data: {
id: this.model.get('id')
},
dataType: 'json',
success: function (resp) {
self.laddaBtn.stop()
self.$el.find('.ladda-button').removeClass('disabled')
},
error: function (resp) {
self.laddaBtn.stop()
self.$el.find('.ladda-button').removeClass('disabled')
}
})
}
})

View File

@ -57,5 +57,16 @@ module.exports = Marionette.LayoutView.extend({
self.render()
})
})
},
templateHelpers: function () {
var self = this
return {
mods: self.options.mods.filter(function (mod) {
return self.model.get('mods').indexOf(mod.get('id')) >= 0
}).map(function (mod) {
return mod.get('name')
})
}
}
})

View File

@ -11,7 +11,7 @@ module.exports = ModListItemView.extend({
templateHelpers: function () {
return {
enabled: this.options.server.get('mods').indexOf(this.model.get('name')) > -1
enabled: this.options.server.get('mods').indexOf(this.model.get('id')) > -1
}
}
})

View File

@ -39,7 +39,7 @@ module.exports = Marionette.LayoutView.extend({
},
onRender: function () {
this.infoView.show(new InfoView({ model: this.model }))
this.infoView.show(new InfoView({ model: this.model, mods: this.mods }))
this.missionsView.show(new MissionsView({ missions: this.missions, model: this.model }))
this.modsView.show(new ModsListView({ collection: this.mods, server: this.model }))
this.parametersView.show(new ParametersListView({ model: this.model }))

View File

@ -0,0 +1,11 @@
<p>
Install mod from <a href='https://steamcommunity.com/app/107410/workshop/' target=_blank>Steam Workshop</a> by id
</p>
<form class="form" role="form" action="/api/mods" method="POST">
<div class="form-group">
<label for="query" class="control-label">ID</label>
<input type="text" class="form-control query" name="query" id="query">
<span class="help-block"></span>
</div>
</form>

View File

@ -3,6 +3,16 @@
Refresh
</a>
<a class="btn btn-primary" id="download" href="#">
<span class="glyphicon glyphicon-search"></span>
Download specific mod
</a>
<a class="btn btn-primary" id="search" href="#">
<span class="glyphicon glyphicon-search"></span>
Search &amp; Download
</a>
<table class="table table-striped">
<thead>
<tr>

View File

@ -2,10 +2,16 @@
<a href='#mods/<%-name%>'><%-name%></a>
</td>
<td>
<% if (downloading) { %>
<div class="progress" style="margin-bottom: 0;">
<div class="progress-bar progress-bar-striped active" role="progressbar" style="width: 100%"></div>
</div>
<% } else { %>
<a class="btn btn-danger btn-xs destroy ladda-button pull-right" data-style="expand-left">
<span class="glyphicon glyphicon-trash"></span>
Delete
</a>
<div class="clearfix"></div>
<% } %>
</td>

View File

@ -0,0 +1,23 @@
<p>
Search mods on <a href='https://steamcommunity.com/app/107410/workshop/' target=_blank>Steam Workshop</a>
</p>
<form class="form" role="form" action="/api/mods" method="POST">
<div class="form-group">
<label for="query" class="control-label">Search</label>
<input type="text" class="form-control query" name="query" id="query">
<span class="help-block"></span>
</div>
</form>
<table class="table table-striped">
<thead>
<tr>
<th>Mod</th>
<th></th>
</tr>
</thead>
<!-- want to insert collection items, here -->
<tbody></tbody>
</table>

View File

@ -0,0 +1,27 @@
<td style="width: 100%;">
<p>
<strong><a href="<%-link%>" target=_blank><%-title%></a></strong>
</p>
<p>
<small><%-description%></strong>
</p>
</td>
<td>
<p>
<% if (downloading()) { %>
<div class="progress" style="margin-bottom: 0;">
<div class="progress-bar progress-bar-striped active" role="progressbar" style="width: 100%"></div>
</div>
<% } else { %>
<a class="btn btn-primary btn-xs install ladda-button pull-right" data-style="expand-left">
<span class="glyphicon glyphicon-save"></span>
Install
</a>
<% } %>
<div class="clearfix"></div>
</p>
<p>
<small class="pull-right"><%-fileSizeFormatted%></small>
<div class="clearfix"></div>
</p>
</td>

View File

@ -1,7 +1,7 @@
<td>
<div class="checkbox">
<label>
<input type="checkbox" value="<%-name%>" <% if (enabled) { %>checked<% } %> >
<input type="checkbox" value="<%-id%>" <% if (enabled) { %>checked<% } %> >
<%-name%>
</label>
</div>

View File

@ -7,6 +7,16 @@ module.exports = function (modsManager) {
res.send(modsManager.mods)
})
router.post('/', function (req, res) {
modsManager.download(req.body.id)
res.status(204).send()
})
router.put('/:mod', function (req, res) {
modsManager.download(req.params.mod)
res.status(204).send()
})
router.delete('/:mod', function (req, res) {
modsManager.delete(req.params.mod, function (err) {
if (err) {
@ -22,5 +32,16 @@ module.exports = function (modsManager) {
res.status(204).send()
})
router.post('/search', function (req, res) {
var query = req.body.query || ''
modsManager.search(query, function (err, mods) {
if (err || !mods) {
res.status(500).send(err)
} else {
res.send(mods)
}
})
})
return router
}

View File

@ -5,14 +5,14 @@ var Server = require('../../lib/server.js')
describe('Server', function () {
describe('generateId()', function () {
it('should include title', function () {
var server = new Server(null, null, { title: 'title.with.lot.of.dots' })
var server = new Server(null, null, null, { title: 'title.with.lot.of.dots' })
server.generateId().should.eql('title-with-lot-of-dots')
})
})
describe('toJSON()', function () {
it('should include title', function () {
var server = new Server(null, null, { title: 'test' })
var server = new Server(null, null, null, { title: 'test' })
server.toJSON().should.have.property('title', 'test')
})
})

21
test/lib/steam_mods.js Normal file
View File

@ -0,0 +1,21 @@
require('should')
var SteamMods = require('../../lib/steam_mods.js')
var dummyMods = [
{
id: 'test',
name: 'test'
}
]
describe('SteamMods', function () {
describe('find()', function () {
it('should find mod', function () {
var steamMods = new SteamMods({})
steamMods.mods = dummyMods
var mod = steamMods.find('test')
mod.should.eql(dummyMods[0])
})
})
})